Skip to content

Commit 2648ac7

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 fd3d0ee commit 2648ac7

File tree

2 files changed

+318
-0
lines changed

2 files changed

+318
-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: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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 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 confirm the key is
26+
* hardware-protected.
27+
*
28+
* Keys are addressed by application name. If a key with the given name already
29+
* exists in the Keystore it is loaded; otherwise a new one is created.
30+
*
31+
* ## Usage
32+
*
33+
* ```kotlin
34+
* val transformer = KeystorePasswordTransformer("my-app")
35+
*
36+
* // Encrypt a password
37+
* val encrypted: String = transformer.encrypt("hunter2")
38+
*
39+
* // Decrypt it back
40+
* val decrypted: String = transformer.decrypt(encrypted)
41+
* assert(decrypted == "hunter2")
42+
*
43+
* // Check hardware backing
44+
* if (transformer.isHardwareBacked) {
45+
* // Key is in TEE or StrongBox
46+
* }
47+
* ```
48+
*/
49+
class KeystorePasswordTransformer(applicationName: String) : PasswordTransformer {
50+
51+
private val key: SecretKey
52+
53+
/**
54+
* Whether the encryption key is backed by secure hardware (TEE or StrongBox).
55+
*
56+
* Returns `false` on emulators or devices without a hardware-backed Keystore,
57+
* where the key is protected in software only.
58+
*/
59+
val isHardwareBacked: Boolean
60+
get() {
61+
val factory = SecretKeyFactory.getInstance(key.algorithm, KEYSTORE_PROVIDER)
62+
val keyInfo = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
63+
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
64+
keyInfo.securityLevel >= KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT
65+
} else {
66+
@Suppress("DEPRECATION")
67+
keyInfo.isInsideSecureHardware
68+
}
69+
}
70+
71+
init {
72+
val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER)
73+
keyStore.load(null)
74+
75+
key = (keyStore.getKey(applicationName, null) as? SecretKey)
76+
?: generateKey(applicationName)
77+
}
78+
79+
// The Cipher and KeyStore APIs throw a wide variety of checked and unchecked
80+
// exceptions (InvalidKeyException, BadPaddingException, IllegalBlockSizeException,
81+
// etc.). We catch broadly and wrap them into a single PasswordTransformerException
82+
// so callers get a uniform error type that crosses the UniFFI boundary cleanly.
83+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
84+
override fun encrypt(password: String): String {
85+
try {
86+
val cipher = Cipher.getInstance(TRANSFORMATION)
87+
cipher.init(Cipher.ENCRYPT_MODE, key)
88+
89+
val iv = cipher.iv
90+
val ciphertext = cipher.doFinal(password.toByteArray(Charsets.UTF_8))
91+
92+
val combined = ByteArray(iv.size + ciphertext.size)
93+
System.arraycopy(iv, 0, combined, 0, iv.size)
94+
System.arraycopy(ciphertext, 0, combined, iv.size, ciphertext.size)
95+
96+
return Base64.encodeToString(combined, Base64.NO_WRAP)
97+
} catch (e: Exception) {
98+
val reason = "${e.javaClass.simpleName}: " +
99+
(e.message ?: "Unknown encryption error")
100+
throw PasswordTransformerException.EncryptionFailed(reason)
101+
}
102+
}
103+
104+
@Suppress("TooGenericExceptionCaught", "SwallowedException") // See comment on encrypt()
105+
override fun decrypt(password: String): String {
106+
try {
107+
val combined = Base64.decode(password, Base64.NO_WRAP)
108+
109+
require(combined.size >= GCM_IV_SIZE + GCM_TAG_SIZE) { "Ciphertext too short" }
110+
111+
val iv = combined.copyOfRange(0, GCM_IV_SIZE)
112+
val ciphertext = combined.copyOfRange(GCM_IV_SIZE, combined.size)
113+
114+
val cipher = Cipher.getInstance(TRANSFORMATION)
115+
cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_SIZE_BITS, iv))
116+
117+
val plaintext = cipher.doFinal(ciphertext)
118+
return String(plaintext, Charsets.UTF_8)
119+
} catch (e: PasswordTransformerException) {
120+
throw e
121+
} catch (e: Exception) {
122+
val reason = "${e.javaClass.simpleName}: " +
123+
(e.message ?: "Unknown decryption error")
124+
throw PasswordTransformerException.DecryptionFailed(reason)
125+
}
126+
}
127+
128+
companion object {
129+
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
130+
private const val TRANSFORMATION = "AES/GCM/NoPadding"
131+
private const val GCM_IV_SIZE = 12
132+
private const val GCM_TAG_SIZE_BITS = 128
133+
private const val GCM_TAG_SIZE = GCM_TAG_SIZE_BITS / 8
134+
private const val AES_KEY_SIZE = 256
135+
136+
private fun generateKey(applicationName: String): SecretKey {
137+
val builder = KeyGenParameterSpec.Builder(
138+
applicationName,
139+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
140+
)
141+
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
142+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
143+
.setKeySize(AES_KEY_SIZE)
144+
145+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
146+
builder.setIsStrongBoxBacked(true)
147+
}
148+
149+
val keyGenerator = KeyGenerator.getInstance(
150+
KeyProperties.KEY_ALGORITHM_AES,
151+
KEYSTORE_PROVIDER
152+
)
153+
154+
try {
155+
keyGenerator.init(builder.build())
156+
return keyGenerator.generateKey()
157+
} catch (_: StrongBoxUnavailableException) {
158+
// StrongBox not available on this device, fall back to TEE
159+
builder.setIsStrongBoxBacked(false)
160+
keyGenerator.init(builder.build())
161+
return keyGenerator.generateKey()
162+
}
163+
}
164+
}
165+
}

0 commit comments

Comments
 (0)