Implement Rust-owned Android Keystore secure storage (#93)
Dependency security audit / rustsec (push) Failing after 23s

This commit is contained in:
hugo committed 2026-10-08 16:22:57 +02:00
1 parent 874c8139a4
commit 1abd75282e
36 files changed
+4380 -37

No files matched your search

@@ -0,0 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity android:name="de.rfc1437.ironstorage.platform.KeystoreTestActivity" android:exported="false" android:theme="@android:style/Theme.Material.Light.NoActionBar" />
</application>
</manifest>
@@ -0,0 +1,359 @@
package de.rfc1437.ironstorage.platform
import android.security.keystore.KeyInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import de.rfc1437.ironstorage.core.KeystoreAuthorization
import de.rfc1437.ironstorage.core.KeystoreException
import de.rfc1437.ironstorage.core.KeystoreFailure
import de.rfc1437.ironstorage.core.KeystoreKeySpecification
import java.security.KeyStore
import java.util.UUID
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class AndroidKeystoreTest {
private val context = InstrumentationRegistry.getInstrumentation().targetContext
@Before fun removeAbandonedFixtureKeys() {
val os = AndroidKeystoreAdapter(context)
os.aliases().filter {
it.startsWith("ironstorage.issue93.fixture.")
}.forEach { os.deleteKey(it) }
}
private fun alias(): String = "ironstorage.issue93.fixture.${UUID.randomUUID()}"
private fun specification(
alias: String,
auth: KeystoreAuthorization = KeystoreAuthorization.None
) = KeystoreKeySpecification(
alias,
auth,
true,
auth == KeystoreAuthorization.StrongBiometric,
false
)
private fun failure(expected: KeystoreFailure, block: () -> Unit) {
try {
block()
throw AssertionError("Expected typed failure")
} catch (
error: KeystoreException
) {
assertEquals(expected, error.kind)
assertEquals("Keystore operation failed", error.message)
}
}
@Test fun deviceUnlockedRoundtripReplaceDeleteAndTamper() {
val os = AndroidKeystoreAdapter(context)
assertFalse(os.deviceState().locked)
val alias = alias()
try {
os.createKey(specification(alias))
failure(KeystoreFailure.AlreadyExists) { os.createKey(specification(alias)) }
val clear = "public-test-token".toByteArray()
val encrypted = os.encrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), clear)
assertTrue(clear.all { it == 0.toByte() })
assertEquals(12, encrypted.nonce.size)
val retrieved = os.decrypt(
alias,
KeystoreAuthorization.None,
byteArrayOf(1, 2),
encrypted
)
try {
assertArrayEquals("public-test-token".toByteArray(), retrieved)
} finally {
retrieved.fill(0)
}
failure(KeystoreFailure.Corrupted) {
os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(3), encrypted)
}
encrypted.bytes[encrypted.bytes.lastIndex] =
(encrypted.bytes.last().toInt() xor 1).toByte()
failure(KeystoreFailure.Corrupted) {
os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), encrypted)
}
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = store.getKey(alias, null) as SecretKey
val info = SecretKeyFactory.getInstance(
key.algorithm,
"AndroidKeyStore"
).getKeySpec(key, KeyInfo::class.java) as KeyInfo
assertEquals(256, info.keySize)
assertFalse(info.isUserAuthenticationRequired)
assertTrue(os.aliases().contains(alias))
os.deleteKey(alias)
failure(KeystoreFailure.MissingKey) {
os.decrypt(alias, KeystoreAuthorization.None, byteArrayOf(1, 2), encrypted)
}
os.deleteKey(alias)
} finally {
os.deleteKey(alias)
}
}
@Test fun biometricRoundtripAndCancellation() {
val os = AndroidKeystoreAdapter(context)
assertTrue(
"Enroll synthetic fingerprint before this test",
os.deviceState().strongBiometric
)
val alias = alias()
ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario ->
lateinit var host: KeystoreTestActivity
scenario.onActivity {
host = it
os.attach(it)
}
try {
os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric))
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
val key = store.getKey(alias, null) as SecretKey
val info = SecretKeyFactory.getInstance(
key.algorithm,
"AndroidKeyStore"
).getKeySpec(key, KeyInfo::class.java) as KeyInfo
assertTrue(info.isUserAuthenticationRequired)
assertTrue(info.isInvalidatedByBiometricEnrollment)
assertEquals(0, info.userAuthenticationValidityDurationSeconds)
host.stage("93:biometric-encrypt")
val encrypted = os.encrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(4),
"public-passphrase".toByteArray()
)
host.stage("93:biometric-decrypt")
val clear = os.decrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(4),
encrypted
)
try {
assertArrayEquals("public-passphrase".toByteArray(), clear)
} finally {
clear.fill(0)
}
host.stage("93:biometric-cancel")
failure(KeystoreFailure.Cancelled) {
os.decrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(4),
encrypted
)
}
host.stage("93:complete")
} catch (error: KeystoreException) {
throw AssertionError("Typed Keystore failure: ${error.kind}", error)
} finally {
os.cancelPending()
os.detach(host)
os.deleteKey(alias)
}
}
}
@Test fun userPresenceSupportsTheConfiguredCredentialFlow() {
val os = AndroidKeystoreAdapter(context)
val alias = alias()
ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario ->
lateinit var host: KeystoreTestActivity
scenario.onActivity {
host = it
os.attach(it)
}
try {
os.createKey(specification(alias, KeystoreAuthorization.UserPresence))
host.stage("93:presence-encrypt")
val encrypted = os.encrypt(
alias,
KeystoreAuthorization.UserPresence,
byteArrayOf(7),
"public-passphrase".toByteArray()
)
host.stage("93:presence-decrypt")
val clear = os.decrypt(
alias,
KeystoreAuthorization.UserPresence,
byteArrayOf(7),
encrypted
)
try {
assertArrayEquals("public-passphrase".toByteArray(), clear)
} finally {
clear.fill(0)
}
} catch (error: KeystoreException) {
throw AssertionError("Typed Keystore failure: ${error.kind}", error)
} finally {
os.cancelPending()
os.detach(host)
os.deleteKey(alias)
}
}
}
@Test
@SdkSuppress(maxSdkVersion = 36)
fun biometricLockoutIsTyped() {
val os = AndroidKeystoreAdapter(context)
val alias = alias()
ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario ->
lateinit var host: KeystoreTestActivity
scenario.onActivity {
host = it
os.attach(it)
}
try {
os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric))
host.stage("93:biometric-lockout")
failure(KeystoreFailure.Lockout) {
os.encrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(8),
"public-passphrase".toByteArray()
)
}
} finally {
os.cancelPending()
os.detach(host)
os.deleteKey(alias)
}
}
}
@Test
@SdkSuppress(minSdkVersion = 37)
fun biometricLockoutRecoveryStillRequiresMatchingBiometric() {
val os = AndroidKeystoreAdapter(context)
val alias = alias()
ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario ->
lateinit var host: KeystoreTestActivity
scenario.onActivity {
host = it
os.attach(it)
}
try {
os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric))
host.stage("93:biometric-lockout-recovery")
failure(KeystoreFailure.Cancelled) {
os.encrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(8),
"public-passphrase".toByteArray()
)
}
} finally {
os.cancelPending()
os.detach(host)
os.deleteKey(alias)
}
}
}
@Test fun addingBiometricInvalidatesTheProtectedKey() = invalidation("93:add-enrollment")
@Test fun removingAllBiometricsInvalidatesTheProtectedKey() = invalidation(
"93:remove-enrollment"
)
@Test fun removingSecureLockInvalidatesTheProtectedKey() =
invalidation("93:remove-lock", KeystoreFailure.MissingKey)
private fun invalidation(
stage: String,
expected: KeystoreFailure = KeystoreFailure.Invalidated
) {
val os = AndroidKeystoreAdapter(context)
assertTrue(os.deviceState().strongBiometric)
val alias = alias()
val credentialAlias = alias()
ActivityScenario.launch(KeystoreTestActivity::class.java).use { scenario ->
lateinit var host: KeystoreTestActivity
scenario.onActivity {
host = it
os.attach(it)
}
try {
os.createKey(specification(credentialAlias))
val credential = os.encrypt(
credentialAlias,
KeystoreAuthorization.None,
byteArrayOf(10),
"public-token".toByteArray()
)
os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric))
host.stage("93:biometric-encrypt")
val encrypted = os.encrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(9),
"public-passphrase".toByteArray()
)
assertTrue(
"Complete the indicated emulator fixture change",
host.awaitStep(stage)
)
if (expected ==
KeystoreFailure.MissingKey
) {
assertFalse(os.aliases().contains(alias))
}
val token = os.decrypt(
credentialAlias,
KeystoreAuthorization.None,
byteArrayOf(10),
credential
)
try {
assertArrayEquals("public-token".toByteArray(), token)
} finally {
token.fill(0)
}
failure(expected) {
os.decrypt(
alias,
KeystoreAuthorization.StrongBiometric,
byteArrayOf(9),
encrypted
)
}
} catch (error: KeystoreException) {
throw AssertionError("Typed Keystore failure: ${error.kind}", error)
} finally {
os.cancelPending()
os.detach(host)
os.deleteKey(alias)
os.deleteKey(credentialAlias)
}
}
}
@Test fun protectedKeyRequiresAnAuthenticationHost() {
val os = AndroidKeystoreAdapter(context)
assertTrue(os.deviceState().strongBiometric)
val alias = alias()
try {
os.createKey(specification(alias, KeystoreAuthorization.StrongBiometric))
val value = "public-test-passphrase".toByteArray()
failure(KeystoreFailure.Unavailable) {
os.encrypt(alias, KeystoreAuthorization.StrongBiometric, byteArrayOf(1), value)
}
assertTrue(value.all { it == 0.toByte() })
} finally {
os.deleteKey(alias)
}
}
}
@@ -0,0 +1,50 @@
package de.rfc1437.ironstorage.platform
import android.os.Bundle
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import androidx.fragment.app.FragmentActivity
import de.rfc1437.ironstorage.platform.test.R
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/** Instrumentation host, omitted from release builds; shows stages, never secrets. */
class KeystoreTestActivity : FragmentActivity() {
private lateinit var label: TextView
private val step = AtomicReference<CountDownLatch?>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
label = TextView(this).apply {
setText(R.string.keystore_fixture_title)
textSize = 24f
}
val next = Button(this).apply {
setText(R.string.keystore_fixture_continue)
setOnClickListener { step.get()?.countDown() }
}
setContentView(
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, 80, 0, 0)
addView(label)
addView(next)
}
)
}
fun awaitStep(value: String): Boolean {
val next = CountDownLatch(1)
step.set(next)
stage(value)
return try {
next.await(180, TimeUnit.SECONDS)
} finally {
step.set(null)
}
}
fun stage(value: String) {
android.util.Log.i("IronStorage93", value)
runOnUiThread { label.text = value }
}
}
@@ -0,0 +1,4 @@
<resources>
<string name="keystore_fixture_title" translatable="false">Keystore fixture</string>
<string name="keystore_fixture_continue" translatable="false">Continue fixture</string>
</resources>