Implement Rust-owned Android Keystore secure storage (#93)
Dependency security audit / rustsec (push) Failing after 23s
Dependency security audit / rustsec (push) Failing after 23s
This commit is contained in:
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>
|
||||
+359
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -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>
|
||||
Reference in new issue
Block a user