Skip to content

Commit a8684c9

Browse files
jkmasselclaude
andcommitted
Add KeystorePasswordTransformer for Android Keystore-backed encryption
Hardware-backed PasswordTransformer for Android using the Android Keystore with AES-256-GCM. Prefers StrongBox (API 28+) and falls back to TEE. Exposes isHardwareBacked property for callers to check the security level. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b133c97 commit a8684c9

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package rs.wordpress.api.android
2+
3+
import android.util.Base64
4+
import org.junit.After
5+
import org.junit.Test
6+
import uniffi.wp_mobile.PasswordTransformerException
7+
import java.security.KeyStore
8+
import kotlin.test.assertEquals
9+
import kotlin.test.assertFailsWith
10+
import kotlin.test.assertNotEquals
11+
import kotlin.test.assertTrue
12+
13+
class KeystorePasswordTransformerTest {
14+
15+
private val testNames = mutableListOf<String>()
16+
17+
private fun createTransformer(
18+
applicationName: String = "test-key-${System.nanoTime()}"
19+
): KeystorePasswordTransformer {
20+
testNames.add(applicationName)
21+
return KeystorePasswordTransformer(applicationName)
22+
}
23+
24+
@After
25+
fun tearDown() {
26+
val keyStore = KeyStore.getInstance("AndroidKeyStore")
27+
keyStore.load(null)
28+
for (name in testNames) {
29+
if (keyStore.containsAlias(name)) {
30+
keyStore.deleteEntry(name)
31+
}
32+
}
33+
}
34+
35+
@Test
36+
fun roundTripEncryptDecrypt() {
37+
val transformer = createTransformer()
38+
val plaintext = "my-secret-password"
39+
40+
val encrypted = transformer.encrypt(plaintext)
41+
val decrypted = transformer.decrypt(encrypted)
42+
43+
assertEquals(plaintext, decrypted)
44+
}
45+
46+
@Test
47+
fun encryptedOutputDiffersFromPlaintext() {
48+
val transformer = createTransformer()
49+
val plaintext = "my-secret-password"
50+
51+
val encrypted = transformer.encrypt(plaintext)
52+
53+
assertNotEquals(plaintext, encrypted)
54+
}
55+
56+
@Test
57+
fun samePlaintextProducesDifferentCiphertext() {
58+
val transformer = createTransformer()
59+
val plaintext = "my-secret-password"
60+
61+
val encrypted1 = transformer.encrypt(plaintext)
62+
val encrypted2 = transformer.encrypt(plaintext)
63+
64+
assertNotEquals(encrypted1, encrypted2)
65+
}
66+
67+
@Test
68+
fun emptyStringRoundTrip() {
69+
val transformer = createTransformer()
70+
71+
val encrypted = transformer.encrypt("")
72+
val decrypted = transformer.decrypt(encrypted)
73+
74+
assertEquals("", decrypted)
75+
}
76+
77+
@Test
78+
fun unicodeRoundTrip() {
79+
val transformer = createTransformer()
80+
val plaintext = "p\u00e4ssw\u00f6rd-\u2603-\ud83d\udd11-\u4f60\u597d"
81+
82+
val encrypted = transformer.encrypt(plaintext)
83+
val decrypted = transformer.decrypt(encrypted)
84+
85+
assertEquals(plaintext, decrypted)
86+
}
87+
88+
@Test
89+
fun longPasswordRoundTrip() {
90+
val transformer = createTransformer()
91+
val plaintext = "a".repeat(10_000)
92+
93+
val encrypted = transformer.encrypt(plaintext)
94+
val decrypted = transformer.decrypt(encrypted)
95+
96+
assertEquals(plaintext, decrypted)
97+
}
98+
99+
@Test
100+
fun wrongKeyAliasFailsToDecrypt() {
101+
val transformer1 = createTransformer()
102+
val transformer2 = createTransformer()
103+
104+
val encrypted = transformer1.encrypt("secret")
105+
106+
assertFailsWith<PasswordTransformerException.DecryptionFailed> {
107+
transformer2.decrypt(encrypted)
108+
}
109+
}
110+
111+
@Test
112+
fun isHardwareBackedReturnsBooleanWithoutCrashing() {
113+
val transformer = createTransformer()
114+
115+
// Just verify it returns without throwing — actual value depends on device
116+
val result = transformer.isHardwareBacked
117+
assertTrue(result || !result)
118+
}
119+
120+
@Test
121+
fun reusingNameLoadsExistingKey() {
122+
val name = "reuse-test-${System.nanoTime()}"
123+
testNames.add(name)
124+
125+
val transformer1 = KeystorePasswordTransformer(name)
126+
val encrypted = transformer1.encrypt("secret")
127+
128+
val transformer2 = KeystorePasswordTransformer(name)
129+
val decrypted = transformer2.decrypt(encrypted)
130+
131+
assertEquals("secret", decrypted)
132+
}
133+
134+
@Test
135+
fun decryptInvalidBase64Fails() {
136+
val transformer = createTransformer()
137+
138+
assertFailsWith<PasswordTransformerException.DecryptionFailed> {
139+
transformer.decrypt("not-valid-base64!!!")
140+
}
141+
}
142+
143+
@Test
144+
fun decryptTruncatedCiphertextFails() {
145+
val transformer = createTransformer()
146+
147+
val tooShort = Base64.encodeToString(ByteArray(10), Base64.NO_WRAP)
148+
149+
assertFailsWith<PasswordTransformerException.DecryptionFailed> {
150+
transformer.decrypt(tooShort)
151+
}
152+
}
153+
}
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
package rs.wordpress.api.android
2+
3+
import android.os.Build
4+
import android.security.keystore.KeyGenParameterSpec
5+
import android.security.keystore.KeyInfo
6+
import android.security.keystore.KeyProperties
7+
import android.security.keystore.StrongBoxUnavailableException
8+
import android.util.Base64
9+
import uniffi.wp_mobile.PasswordTransformer
10+
import uniffi.wp_mobile.PasswordTransformerException
11+
import java.security.KeyStore
12+
import javax.crypto.Cipher
13+
import javax.crypto.KeyGenerator
14+
import javax.crypto.SecretKey
15+
import javax.crypto.SecretKeyFactory
16+
import javax.crypto.spec.GCMParameterSpec
17+
18+
/**
19+
* A [PasswordTransformer] implementation that uses the Android Keystore for
20+
* hardware-backed AES-256-GCM encryption.
21+
*
22+
* The AES key is generated and stored inside the Android Keystore — it never
23+
* leaves the secure hardware (TEE or StrongBox). On devices that support
24+
* StrongBox (API 28+), the key is stored in the dedicated secure element for
25+
* stronger isolation. Check [isHardwareBacked] to determine the security level.
26+
*
27+
* Keys are addressed by application name. If a key with the given name already
28+
* exists in the Keystore it is loaded; otherwise a new one is created. Use
29+
* distinct names to maintain separate keys per account or context.
30+
*
31+
* ## Usage
32+
*
33+
* ```kotlin
34+
* // Create a transformer with the default application name
35+
* val transformer = KeystorePasswordTransformer()
36+
*
37+
* // Or use a custom name for per-account keys
38+
* val transformer = KeystorePasswordTransformer("account-42")
39+
*
40+
* // Encrypt a password
41+
* val encrypted: String = transformer.encrypt("hunter2")
42+
*
43+
* // Decrypt it back
44+
* val decrypted: String = transformer.decrypt(encrypted)
45+
* assert(decrypted == "hunter2")
46+
*
47+
* // Check hardware backing
48+
* if (transformer.isHardwareBacked) {
49+
* // Key is in TEE or StrongBox
50+
* }
51+
* ```
52+
*/
53+
class KeystorePasswordTransformer(applicationName: String = DEFAULT_ALIAS) : PasswordTransformer {
54+
55+
private val key: SecretKey
56+
57+
val isHardwareBacked: Boolean
58+
get() {
59+
val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE_PROVIDER)
60+
val keyInfo = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
61+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
62+
keyInfo.securityLevel >= KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT
63+
} else {
64+
@Suppress("DEPRECATION")
65+
keyInfo.isInsideSecureHardware
66+
}
67+
}
68+
69+
init {
70+
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
71+
keyStore.load(null)
72+
73+
key = if (keyStore.containsAlias(applicationName)) {
74+
keyStore.getKey(applicationName, null) as SecretKey
75+
} else {
76+
generateKey(applicationName)
77+
}
78+
}
79+
80+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
81+
override fun encrypt(password: String): String {
82+
try {
83+
val cipher = Cipher.getInstance(TRANSFORMATION)
84+
cipher.init(Cipher.ENCRYPT_MODE, key)
85+
86+
val iv = cipher.iv
87+
val ciphertext = cipher.doFinal(password.toByteArray(Charsets.UTF_8))
88+
89+
val combined = ByteArray(iv.size + ciphertext.size)
90+
System.arraycopy(iv, 0, combined, 0, iv.size)
91+
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
92+
93+
return Base64.encodeToString(combined, Base64.NO_WRAP)
94+
} catch (e: Exception) {
95+
val reason = "${e.javaClass.simpleName}: " +
96+
(e.message ?: "Unknown encryption error")
97+
throw PasswordTransformerException.EncryptionFailed(reason)
98+
}
99+
}
100+
101+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
102+
override fun decrypt(password: String): String {
103+
try {
104+
val combined = Base64.decode(password, Base64.NO_WRAP)
105+
106+
require(combined.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Ciphertext too short" }
107+
108+
val iv = combined.copyOfRange(0, GCM_IV_SIZE)
109+
val ciphertext = combined.copyOfRange(GCM_IV_SIZE, combined.size)
110+
111+
val cipher = Cipher.getInstance(TRANSFORMATION)
112+
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_SIZE_BITS, iv))
113+
114+
val plaintext = cipher.doFinal(ciphertext)
115+
return String(plaintext, Charsets.UTF_8)
116+
} catch (e: PasswordTransformerException) {
117+
throw e
118+
} catch (e: Exception) {
119+
val reason = "${e.javaClass.simpleName}: " +
120+
(e.message ?: "Unknown decryption error")
121+
throw PasswordTransformerException.DecryptionFailed(reason)
122+
}
123+
}
124+
125+
companion object {
126+
private const val DEFAULT_ALIAS = "wordpress-rs-password-transformer"
127+
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
128+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
129+
private const val GCM_IV_SIZE = 12
130+
private const val GCM_TAG_SIZE_BITS = 128
131+
private const val GCM_TAG_SIZE = GCM_TAG_SIZE_BITS / 8
132+
private const val AES_KEY_SIZE = 256
133+
134+
private fun generateKey(applicationName: String): SecretKey {
135+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
136+
try {
137+
return generateKeyWithSpec(applicationName, strongBox = true)
138+
} catch (_: StrongBoxUnavailableException) {
139+
// StrongBox not available on this device, fall back to TEE
140+
}
141+
}
142+
return generateKeyWithSpec(applicationName, strongBox = false)
143+
}
144+
145+
private fun generateKeyWithSpec(applicationName: String, strongBox: Boolean): SecretKey {
146+
val builder = KeyGenParameterSpec.Builder(
147+
applicationName,
148+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
149+
)
150+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
151+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
152+
.setKeySize(AES_KEY_SIZE)
153+
154+
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
155+
builder.setIsStrongBoxBacked(true)
156+
}
157+
158+
val keyGenerator = KeyGenerator.getInstance(
159+
KeyProperties.KEY_ALGORITHM_AES,
160+
KEYSTORE_PROVIDER
161+
)
162+
keyGenerator.init(builder.build())
163+
return keyGenerator.generateKey()
164+
}
165+
}
166+
}

0 commit comments

Comments
 (0)