From 2ec71e56553712db5e788b5d3ff8291582f740a6 Mon Sep 17 00:00:00 2001 From: Peter Sorotokin Date: Thu, 23 Jan 2025 16:19:36 -0800 Subject: [PATCH] Converts storage and secure area to new asynchronous APIs. Signed-off-by: Peter Sorotokin --- .../securearea/cloud/CloudSecureAreaTest.kt | 85 +++++-- .../android/securearea/cloud/CloudKeyInfo.kt | 3 +- .../securearea/cloud/CloudSecureArea.kt | 227 +++++++++++------ identity-android-legacy/build.gradle.kts | 1 + .../MigrateFromKeystoreICStoreTest.java | 177 ------------- .../legacy/MigrateFromKeystoreICStoreTest.kt | 186 ++++++++++++++ .../android/identity/android/legacy/Util.kt | 59 ----- identity-android/build.gradle.kts | 1 + ...roidKeystoreSecureAreaDocumentStoreTest.kt | 40 ++- .../DeviceRetrievalHelperTest.kt | 67 +++-- .../deviceretrieval/DeviceRetrievalHelper.kt | 13 +- .../ui/presentment/PresentmentSource.kt | 2 +- .../digitalCredentialsPresentment.kt | 2 +- .../ui/presentment/mdocPresentment.kt | 2 +- .../securearea/cloud/CloudSecureAreaServer.kt | 30 +-- .../flow/server/FlowEnvironmentStorageExt.kt | 9 + .../android/identity/flow/server/Storage.kt | 56 ----- .../identity/flow}/EnvironmentCacheExt.kt | 2 +- .../issuance/authenticationUtilities.kt | 2 +- .../funke/FunkeIssuingAuthorityState.kt | 47 ++-- .../identity/issuance/funke/FunkeUtil.kt | 7 +- ...equestCredentialsUsingProofOfPossession.kt | 8 +- .../funke/openid4VciIssuerMetadata.kt | 4 +- .../hardcoded/IssuingAuthorityState.kt | 51 ++-- .../issuance/hardcoded/ProofingState.kt | 20 +- .../wallet/ApplicationSupportState.kt | 38 ++- .../issuance/wallet/AuthenticationState.kt | 48 ++-- .../mdoc/credential/MdocCredential.kt | 26 +- .../mdoc/response/DocumentGenerator.kt | 9 +- .../response/DeviceResponseGeneratorTest.kt | 94 ++++--- identity-sdjwt/build.gradle.kts | 1 + .../sdjwt/SdJwtVerifiableCredential.kt | 2 +- .../credential/KeyBoundSdJwtVcCredential.kt | 25 +- .../credential/KeylessSdJwtVcCredential.kt | 21 +- .../com/android/identity/sdjwt/SdJwtVcTest.kt | 42 ++-- .../AndroidKeystoreSecureAreaTest.kt | 72 ++++-- .../securearea/AndroidKeystoreKeyInfo.kt | 3 +- .../securearea/AndroidKeystoreSecureArea.kt | 89 +++++-- .../identity/device/DeviceCheck.android.kt | 6 +- .../storage/android/AndroidStorage.kt | 4 +- .../kotlin/com/android/identity/cose/Cose.kt | 2 +- .../android/identity/credential/Credential.kt | 58 +++-- .../identity/credential/CredentialFactory.kt | 12 +- .../credential/SecureAreaBoundCredential.kt | 43 ++-- .../com/android/identity/document/Document.kt | 101 +++++--- .../identity/document/DocumentStore.kt | 67 ++--- .../android/identity/document/DocumentUtil.kt | 4 +- .../android/identity/securearea/KeyInfo.kt | 1 + .../android/identity/securearea/SecureArea.kt | 16 +- .../identity/securearea/SecureAreaProvider.kt | 38 +++ .../securearea/SecureAreaRepository.kt | 145 ++++++----- .../securearea/software/SoftwareKeyInfo.kt | 2 + .../securearea/software/SoftwareSecureArea.kt | 84 +++++-- .../identity/storage/base/BaseStorage.kt | 32 +++ .../storage/ephemeral/EphemeralStorage.kt | 27 ++ .../storage/ephemeral/EphemeralStorageItem.kt | 31 +++ .../ephemeral/EphemeralStorageTable.kt | 64 ++--- .../identity/document/DocumentStoreTest.kt | 234 ++++++++++-------- .../identity/document/DocumentUtilTest.kt | 109 ++++---- .../securearea/SoftwareSecureAreaTest.kt | 75 +++--- .../storage/EphemeralStorageEngineTest.kt | 65 +++++ .../identity/storage/EphemeralStorageTest.kt | 75 +++--- .../securearea/SecureEnclaveKeyInfo.kt | 3 +- .../securearea/SecureEnclaveSecureArea.kt | 65 +++-- .../age_verifier_mdl/TransferHelper.kt | 19 +- .../identity/preconsent_mdl/MainActivity.kt | 21 +- .../preconsent_mdl/PresentationActivity.kt | 24 +- .../identity/preconsent_mdl/TransferHelper.kt | 24 +- samples/testapp/build.gradle.kts | 2 + .../identity/testapp/PlatformAndroid.kt | 25 +- .../AndroidKeystoreSecureAreaScreenAndroid.kt | 72 +++--- .../ui/CloudSecureAreaScreenAndroid.kt | 48 ++-- .../com/android/identity/testapp/Platform.kt | 6 +- .../testapp/TestAppPresentmentSource.kt | 4 +- .../android/identity/testapp/TestAppUtils.kt | 61 ++--- .../testapp/provisioning/ServerData.kt | 11 + .../provisioning/WalletServerProvider.kt | 55 ++-- .../testapp/ui/SoftwareSecureAreaScreen.kt | 50 ++-- .../android/identity/testapp/PlatformIos.kt | 44 +++- .../ui/SecureEnclaveSecureAreaScreenIos.kt | 39 ++- .../identity/testapp/Platform.iosX64.kt | 2 +- .../identity/server/BaseFlowHttpServlet.kt | 20 +- .../server/SecureAreaStorageAdapter.kt | 49 ---- .../identity/server/ServerEnvironment.kt | 29 ++- .../android/identity/server/ServerStorage.kt | 158 ------------ .../identity/server/ServerStorageTest.kt | 71 ------ .../openid4vci/AuthorizeChallengeServlet.kt | 7 +- .../server/openid4vci/AuthorizeServlet.kt | 15 +- .../identity/server/openid4vci/BaseServlet.kt | 20 +- .../openid4vci/CredentialRequestServlet.kt | 8 +- .../server/openid4vci/CredentialServlet.kt | 11 +- .../openid4vci/FinishAuthorizationServlet.kt | 6 +- .../openid4vci/Openid4VpResponseServlet.kt | 9 +- .../identity/server/openid4vci/ParServlet.kt | 8 - .../server/openid4vci/TokenServlet.kt | 9 +- .../server/openid4vci/createSession.kt | 8 +- .../identity/server/openid4vci/messages.kt | 9 +- .../identity/wallet/server/AdminServlet.kt | 55 ++-- .../wallet/server/CloudSecureAreaServlet.kt | 24 +- .../identity/wallet/server/LandingServlet.kt | 8 +- .../identity/wallet/server/VerifierServlet.kt | 113 ++++----- settings.gradle.kts | 2 +- .../identity/remote/StorageImplTest.kt | 82 ------ .../remote/LocalDevelopmentEnvironment.kt | 13 +- .../identity/issuance/remote/StorageImpl.kt | 176 ------------- .../issuance/remote/WalletServerProvider.kt | 41 +-- .../wallet/DocumentModel.kt | 139 ++++++----- .../wallet/NfcEngagementHandler.kt | 13 +- .../wallet/PresentationActivity.kt | 4 +- .../wallet/ProvisioningViewModel.kt | 41 +-- .../wallet/WalletApplication.kt | 56 ++--- .../credman/CredmanPresentationActivity.kt | 152 ++++++------ .../wallet/credman/CredmanRegistry.kt | 7 +- .../logging/DocumentUpdateCheckEvent.kt | 7 +- .../wallet/logging/Event.kt | 1 + .../wallet/logging/EventLogger.kt | 59 +++-- .../wallet/logging/MdocPresentationEvent.kt | 9 +- .../OpenID4VPPresentationActivity.kt | 38 ++- .../wallet/presentation/PresentmentFlow.kt | 6 +- .../wallet/presentation/TransferHelper.kt | 10 +- .../document/DocumentInfoScreen.kt | 4 +- .../ui/destination/document/EventLogScreen.kt | 18 +- .../provisioncredential/EvidenceRequest.kt | 44 ++-- .../ProvisionCredentialScreen.kt | 12 +- .../ui/destination/settings/SettingsScreen.kt | 13 +- .../wallet/SelfSignedMdlTest.kt | 16 +- .../wallet/logging/EventLoggerTest.kt | 11 +- 127 files changed, 2619 insertions(+), 2433 deletions(-) delete mode 100644 identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java create mode 100644 identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.kt create mode 100644 identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/FlowEnvironmentStorageExt.kt delete mode 100644 identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/Storage.kt rename {identity-issuance/src/main/java/com/android/identity/issuance/common => identity-flow/src/jvmMain/kotlin/com/android/identity/flow}/EnvironmentCacheExt.kt (97%) create mode 100644 identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaProvider.kt create mode 100644 identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageItem.kt create mode 100644 identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageEngineTest.kt create mode 100644 samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/ServerData.kt delete mode 100644 server-env/src/main/java/com/android/identity/server/SecureAreaStorageAdapter.kt delete mode 100644 server-env/src/main/java/com/android/identity/server/ServerStorage.kt delete mode 100644 server-env/src/test/java/com/android/identity/server/ServerStorageTest.kt delete mode 100644 wallet/src/androidTest/java/com/android/identity/remote/StorageImplTest.kt delete mode 100644 wallet/src/main/java/com/android/identity/issuance/remote/StorageImpl.kt diff --git a/identity-android-csa/src/androidTest/java/com/android/identity/android/securearea/cloud/CloudSecureAreaTest.kt b/identity-android-csa/src/androidTest/java/com/android/identity/android/securearea/cloud/CloudSecureAreaTest.kt index 72482838a..512e7ab0d 100644 --- a/identity-android-csa/src/androidTest/java/com/android/identity/android/securearea/cloud/CloudSecureAreaTest.kt +++ b/identity-android-csa/src/androidTest/java/com/android/identity/android/securearea/cloud/CloudSecureAreaTest.kt @@ -25,6 +25,9 @@ import com.android.identity.securearea.cloud.fromCbor import com.android.identity.securearea.cloud.toCbor import com.android.identity.storage.EphemeralStorageEngine import com.android.identity.storage.StorageEngine +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec +import com.android.identity.storage.ephemeral.EphemeralStorage import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -39,6 +42,7 @@ import java.security.MessageDigest import java.security.NoSuchAlgorithmException import java.security.Security import kotlin.random.Random +import kotlin.reflect.KClass import kotlin.time.Duration import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes @@ -55,13 +59,14 @@ class CloudSecureAreaTest { var serverTime = Instant.fromEpochMilliseconds(0) internal inner class LoopbackCloudSecureArea( - context: Context, - storageEngine: StorageEngine, - packageToAllow: String?, - ) : CloudSecureArea(context, storageEngine, "CloudSecureArea", "uri-not-used") { - private val server: CloudSecureAreaServer - - init { + private val context: Context, + storageTable: StorageTable, + private val packageToAllow: String?, + ) : CloudSecureArea(storageTable, "CloudSecureArea", "uri-not-used") { + private lateinit var server: CloudSecureAreaServer + + public override suspend fun initialize() { + super.initialize() val enclaveBoundKey = Random.nextBytes(32) val attestationKeySubject = "CN=Cloud Secure Area Attestation Root" @@ -164,9 +169,10 @@ class CloudSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -186,9 +192,10 @@ class CloudSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -221,9 +228,10 @@ class CloudSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -261,9 +269,10 @@ class CloudSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -291,9 +300,10 @@ class CloudSecureAreaTest { // Setup the server to only accept our package name. This should cause register to succeed(). val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), context.packageName ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -308,9 +318,10 @@ class CloudSecureAreaTest { // cause register() to fail. val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), "com.android.externalstorage" ) + csa.initialize() try { csa.register( "", @@ -328,9 +339,10 @@ class CloudSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "", PassphraseConstraints.NONE) { true } @@ -371,16 +383,17 @@ class CloudSecureAreaTest { } suspend fun testWrongPassphraseDelayHelper( - useKey: (alias: String, + useKey: suspend (alias: String, csa: CloudSecureArea, unlockData: CloudKeyUnlockData?) -> Unit ) { val context = InstrumentationRegistry.getTargetContext() val csa = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa.initialize() csa.register( "1111", @@ -432,15 +445,15 @@ class CloudSecureAreaTest { // last minute. serverTime = Instant.fromEpochMilliseconds(0) useKey("testKey1", csa, correctPassphrase) - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } serverTime = Instant.fromEpochMilliseconds(15 * 1000) - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } serverTime = Instant.fromEpochMilliseconds(30 * 1000) - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } @@ -456,7 +469,7 @@ class CloudSecureAreaTest { // Let's do another failed attempt and then try again... this should block // until T = 75 seconds because we had failed attempts at T=15 and T=30 already. - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } useKey("testKey1", csa, correctPassphrase) @@ -467,13 +480,13 @@ class CloudSecureAreaTest { // Also check that if one key from the client is blocked, so are all others. Also // check here that operations on keys w/o passphrases aren't blocked - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } // testKey3NoPassphrase shouldn't be blocked @@ -490,9 +503,10 @@ class CloudSecureAreaTest { // end we're creating a new client w/ a passphrase protected key. val csa2 = LoopbackCloudSecureArea( context, - EphemeralStorageEngine(), + EphemeralStorage().getTable(tableSpec), null ) + csa2.initialize() csa2.register( "9876", PassphraseConstraints.PIN_FOUR_DIGITS, @@ -507,13 +521,13 @@ class CloudSecureAreaTest { correctPassphraseClient2.passphrase = "9876" // Now use up all failed attempts on client 1 - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } - Assert.assertThrows(KeyLockedException::class.java) { + assertThrows(KeyLockedException::class) { useKey("testKey1", csa, incorrectPassphrase) } Assert.assertEquals(Instant.fromEpochMilliseconds(2000 * 1000), serverTime) @@ -536,5 +550,22 @@ class CloudSecureAreaTest { throw RuntimeException(e) } } + + private suspend fun assertThrows(clazz: KClass, body: suspend () -> Unit) { + try { + body() + Assert.fail("Expected exception $clazz, no exception was thrown") + } catch (err: Throwable) { + if (!clazz.isInstance(err)) { + throw err + } + } + } + + private val tableSpec = StorageTableSpec( + name = "TestCloudSecureArea", + supportPartitions = true, + supportExpiration = false + ) } } \ No newline at end of file diff --git a/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudKeyInfo.kt b/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudKeyInfo.kt index e3d40940e..c2121f5b7 100644 --- a/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudKeyInfo.kt +++ b/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudKeyInfo.kt @@ -11,6 +11,7 @@ import kotlinx.datetime.Instant * Cloud Secure Area specific class for information about a key. */ class CloudKeyInfo internal constructor( + alias: String, attestation: KeyAttestation, keyPurposes: Set, @@ -56,5 +57,5 @@ class CloudKeyInfo internal constructor( */ val isStrongBoxBacked: Boolean -) : KeyInfo(attestation.publicKey, keyPurposes, attestation) +) : KeyInfo(alias, attestation.publicKey, keyPurposes, attestation) diff --git a/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudSecureArea.kt b/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudSecureArea.kt index 0eda4f150..26064d3b4 100644 --- a/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudSecureArea.kt +++ b/identity-android-csa/src/main/java/com/android/identity/android/securearea/cloud/CloudSecureArea.kt @@ -1,6 +1,5 @@ package com.android.identity.android.securearea.cloud -import android.content.Context import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties @@ -20,6 +19,7 @@ import com.android.identity.crypto.fromJavaX509Certificates import com.android.identity.crypto.javaX509Certificate import com.android.identity.securearea.AttestationExtension import com.android.identity.securearea.KeyAttestation +import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.KeyInvalidatedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.PassphraseConstraints @@ -51,7 +51,10 @@ import com.android.identity.securearea.cloud.fromCbor import com.android.identity.securearea.cloud.toCbor import com.android.identity.securearea.fromCbor import com.android.identity.securearea.toCbor +import com.android.identity.storage.Storage import com.android.identity.storage.StorageEngine +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity.util.toHex import io.ktor.client.HttpClient @@ -65,8 +68,8 @@ import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking import kotlinx.datetime.Instant +import kotlinx.io.bytestring.ByteString import org.bouncycastle.asn1.ASN1InputStream import org.bouncycastle.asn1.ASN1OctetString import java.io.IOException @@ -102,14 +105,12 @@ import kotlin.time.Duration.Companion.milliseconds * for all keys used in the passed-in [storageEngine]. As such, it's safe to use the same * [StorageEngine] instance for multiple [CloudSecureArea] instances. * - * @param context the application context. - * @param storageEngine the storage engine to use for storing metadata about keys. + * @param storageTable the storage to use for storing metadata about keys. * @param identifier an identifier for the Cloud Secure Area. * @param serverUrl the URL the Cloud Secure Area is using. */ -open class CloudSecureArea( - private val context: Context, - private val storageEngine: StorageEngine, +open class CloudSecureArea protected constructor( + private val storageTable: StorageTable, final override val identifier: String, val serverUrl: String, ) : SecureArea { @@ -133,15 +134,14 @@ open class CloudSecureArea( } private var cloudBindingKey: EcPublicKey? = null - private var registrationContext: ByteArray? = null + private var registrationContext: ByteString? = null - init { + protected open suspend fun initialize() { require(identifier.startsWith(IDENTIFIER_PREFIX)) - - storageEngine["${identifier}_cloudBindingKey"]?.let { - cloudBindingKey = Cbor.decode(it).asCoseKey.ecPublicKey + storageTable.get(BINDING_KEY, identifier)?.let { bindingKeyData -> + cloudBindingKey = Cbor.decode(bindingKeyData.toByteArray()).asCoseKey.ecPublicKey } - registrationContext = storageEngine["${identifier}_registrationContext"] + registrationContext = storageTable.get(REGISTRATION_CONTEXT, identifier) } open suspend fun communicateLowlevel( @@ -210,6 +210,7 @@ open class CloudSecureArea( kpg.initialize(builder.build()) kpg.generateKeyPair() val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val deviceBindingKeyAttestation = X509CertChain.fromJavaX509Certificates(ks.getCertificateChain(deviceBindingKeyAlias)) @@ -229,14 +230,19 @@ open class CloudSecureArea( // Ready to go... cloudBindingKey = response1.cloudBindingKeyAttestation.certificates[0].ecPublicKey - registrationContext = response1.serverState + registrationContext = ByteString(response1.serverState) // ... and save for future use - storageEngine.put( - "${identifier}_cloudBindingKey", - Cbor.encode(cloudBindingKey!!.toCoseKey().toDataItem()) + storageTable.insert( + key = BINDING_KEY, + partitionId = identifier, + data = ByteString(Cbor.encode(cloudBindingKey!!.toCoseKey().toDataItem())) + ) + storageTable.insert( + key = REGISTRATION_CONTEXT, + partitionId = identifier, + data = registrationContext!! ) - storageEngine.put("${identifier}_registrationContext", registrationContext!!) // We need E2EE before entering the second stage of registration - this is because we // need the data exchanged in this stage (e.g. the passphrase) to be encrypted so only @@ -249,10 +255,17 @@ open class CloudSecureArea( val stage2Response0 = CloudSecureAreaProtocol.Command.fromCbor(communicateE2EE(stage2Request0.toCbor())) as CloudSecureAreaProtocol.RegisterStage2Response0 - registrationContext = stage2Response0.serverState - storageEngine.put("${identifier}_registrationContext", registrationContext!!) - storageEngine.put("${identifier}_passphraseConstraints", passphraseConstraints.toCbor()) - + registrationContext = ByteString(stage2Response0.serverState) + storageTable.update( + key = REGISTRATION_CONTEXT, + partitionId = identifier, + data = registrationContext!! + ) + storageTable.insert( + key = PASSPHRASE_CONSTRAINTS, + partitionId = identifier, + data = ByteString(passphraseConstraints.toCbor()) + ) } catch (e: Throwable) { throw CloudException(e) } @@ -264,17 +277,17 @@ open class CloudSecureArea( * @return the [PassphraseConstraints] passed to to the [register] method. * @throws IllegalStateException if not registered with a Cloud Secure Area instance. */ - val passphraseConstraints: PassphraseConstraints - get() { - val encoded = storageEngine.get("${identifier}_passphraseConstraints") - ?: throw IllegalStateException("Not registered with CSA") - return PassphraseConstraints.fromCbor(encoded) - } + suspend fun getPassphraseConstraints(): PassphraseConstraints { + val encoded = storageTable.get(key = PASSPHRASE_CONSTRAINTS, partitionId = identifier) + ?: throw IllegalStateException("Not registered with CSA") + return PassphraseConstraints.fromCbor(encoded.toByteArray()) + } - fun unregister() { + suspend fun unregister() { // TODO: should we send a RPC to server to let them know we're bailing? - storageEngine.delete("${identifier}_cloudBindingKey") - storageEngine.delete("${identifier}_registrationContext") + storageTable.delete(BINDING_KEY) + storageTable.delete(REGISTRATION_CONTEXT) + storageTable.delete(PASSPHRASE_CONSTRAINTS) cloudBindingKey = null registrationContext = null skDevice = null @@ -323,15 +336,15 @@ open class CloudSecureArea( } } - private fun setupE2EE(forceSetup: Boolean) { + private suspend fun setupE2EE(forceSetup: Boolean) { // No need to setup E2EE if it's already up... if (!forceSetup && skDevice != null) { return } var response: ByteArray try { - val request0 = E2EESetupRequest0(registrationContext!!) - response = runBlocking { communicate(serverUrl, request0.toCbor()) } + val request0 = E2EESetupRequest0(registrationContext!!.toByteArray()) + response = communicate(serverUrl, request0.toCbor()) val response0 = CloudSecureAreaProtocol.Command.fromCbor(response) as E2EESetupResponse0 val deviceNonce = Random.Default.nextBytes(32) val eDeviceKey = Crypto.createEcPrivateKey(EcCurve.P256) @@ -344,6 +357,7 @@ open class CloudSecureArea( .build() ) val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val deviceBindingKeyEntry = ks.getEntry("DeviceBindingKey", null) ?: throw IllegalArgumentException("No entry for DeviceBindingKey") @@ -358,7 +372,7 @@ open class CloudSecureArea( EcSignature.fromDerEncoded(EcCurve.P256.bitSize, derSignature), response0.serverState ) - response = runBlocking { communicate(serverUrl, request1.toCbor()) } + response = communicate(serverUrl, request1.toCbor()) val response1 = CloudSecureAreaProtocol.Command.fromCbor(response) as E2EESetupResponse1 val dataSignedByTheCloud = Cbor.encode( CborArray.builder() @@ -432,11 +446,11 @@ open class CloudSecureArea( } // This is internal rather than private b/c it's used in testPassphraseCannotBeChanged() - internal fun communicateE2EE(requestData: ByteArray): ByteArray { + internal suspend fun communicateE2EE(requestData: ByteArray): ByteArray { return communicateE2EEInternal(requestData, 0) } - private fun communicateE2EEInternal( + private suspend fun communicateE2EEInternal( requestData: ByteArray, callDepth: Int ): ByteArray { @@ -444,9 +458,8 @@ open class CloudSecureArea( encryptToCloud(requestData), e2eeContext!! ) - val (httpResponseCode, response) = runBlocking { - communicateLowlevel(serverUrl, requestWrapper.toCbor()) - } + val (httpResponseCode, response) = communicateLowlevel(serverUrl, requestWrapper.toCbor()) + if (httpResponseCode == HttpURLConnection.HTTP_BAD_REQUEST) { // This status code means that decryption failed if (callDepth > 10) { @@ -463,10 +476,18 @@ open class CloudSecureArea( return decryptFromCloud(responseWrapper.encryptedResponse) } - override fun createKey( - alias: String, + override suspend fun createKey( + alias: String?, createKeySettings: com.android.identity.securearea.CreateKeySettings - ) { + ): KeyInfo { + if (alias != null) { + // If the key with the given alias exists, it is silently overwritten. + // TODO: review if this is the semantics we want + storageTable.delete( + key = alias, + partitionId = identifier + ) + } val cSettings = if (createKeySettings is CloudCreateKeySettings) { createKeySettings } else { @@ -493,7 +514,12 @@ open class CloudSecureArea( cSettings.attestationChallenge ) val response0 = CloudSecureAreaProtocol.Command.fromCbor(communicateE2EE(request0.toCbor())) as CreateKeyResponse0 - val localKeyAlias = getLocalKeyAlias(alias) + val newKeyAlias = storageTable.insert( + key = alias, + partitionId = identifier, + data = ByteString() + ) + val localKeyAlias = getLocalKeyAlias(newKeyAlias) val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore") val builder = KeyGenParameterSpec.Builder(localKeyAlias, KeyProperties.PURPOSE_SIGN) @@ -530,49 +556,62 @@ open class CloudSecureArea( kpg.initialize(builder.build()) kpg.generateKeyPair() val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val request1 = CreateKeyRequest1( X509CertChain.fromJavaX509Certificates(ks.getCertificateChain(localKeyAlias)), response0.serverState ) val response1 = CloudSecureAreaProtocol.Command.fromCbor(communicateE2EE(request1.toCbor())) as CreateKeyResponse1 - storageEngine.put( - getStoragePrefixForPerKeyServerState(alias), - response1.serverState + storageTable.update( + key = newKeyAlias, + partitionId = identifier, + data = ByteString(response1.serverState) ) - saveKeyMetadata(alias, cSettings, response1.remoteKeyAttestation) + saveKeyMetadata(newKeyAlias, cSettings, response1.remoteKeyAttestation) + return getKeyInfo(newKeyAlias) } catch (e: Exception) { throw CloudException(e) } } - override fun deleteKey(alias: String) { + override suspend fun deleteKey(alias: String) { val localKeyAlias = getLocalKeyAlias(alias) val ks: KeyStore try { ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) if (!ks.containsAlias(localKeyAlias)) { Logger.w(TAG, "Key with alias '$localKeyAlias' doesn't exist") } else { ks.deleteEntry(localKeyAlias) } - storageEngine.delete(getStoragePrefixForPerKeyServerState(alias)) - storageEngine.delete(getStoragePrefixForKeyInfo(alias)) + storageTable.delete( + key = alias, + partitionId = identifier + ) + storageTable.delete( + key = METADATA_PREFIX + alias, + partitionId = identifier + ) } catch (e: Exception) { throw IllegalStateException("Error loading keystore", e) } } @Throws(com.android.identity.securearea.KeyLockedException::class) - override fun sign( + override suspend fun sign( alias: String, signatureAlgorithm: Algorithm, dataToSign: ByteArray, keyUnlockData: com.android.identity.securearea.KeyUnlockData? ): EcSignature { var resultingSignature: EcSignature? = null - val keyContext = storageEngine[getStoragePrefixForPerKeyServerState(alias)] + val keyContext = storageTable.get( + key = alias, + partitionId = identifier + ) setupE2EE(false) var response: ByteArray @@ -589,7 +628,7 @@ open class CloudSecureArea( try { val request0 = - SignRequest0(signatureAlgorithm.coseAlgorithmIdentifier, dataToSign, keyContext!!) + SignRequest0(signatureAlgorithm.coseAlgorithmIdentifier, dataToSign, keyContext!!.toByteArray()) response = communicateE2EE(request0.toCbor()) val response0 = CloudSecureAreaProtocol.Command.fromCbor(response) as SignResponse0 val dataToSignLocally = Cbor.encode( @@ -600,6 +639,7 @@ open class CloudSecureArea( ) val localKeyAlias = getLocalKeyAlias(alias) val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val deviceBindingKeyEntry = ks.getEntry(localKeyAlias, null) ?: throw KeyInvalidatedException("This key is no longer available") @@ -641,9 +681,7 @@ open class CloudSecureArea( } CloudSecureAreaProtocol.RESULT_TOO_MANY_PASSPHRASE_ATTEMPTS -> { - runBlocking { - delayForBruteforceMitigation(response1.waitDurationMillis.milliseconds) - } + delayForBruteforceMitigation(response1.waitDurationMillis.milliseconds) tryAgain = true } @@ -688,13 +726,13 @@ open class CloudSecureArea( } @Throws(com.android.identity.securearea.KeyLockedException::class) - override fun keyAgreement( + override suspend fun keyAgreement( alias: String, otherKey: EcPublicKey, keyUnlockData: com.android.identity.securearea.KeyUnlockData? ): ByteArray { var Zab: ByteArray? = null - val keyContext = storageEngine[getStoragePrefixForPerKeyServerState(alias)] + val keyContext = storageTable.get(key = alias, partitionId = identifier) setupE2EE(false) var response: ByteArray @@ -710,7 +748,7 @@ open class CloudSecureArea( } try { - val request0 = KeyAgreementRequest0(otherKey.toCoseKey(), keyContext!!) + val request0 = KeyAgreementRequest0(otherKey.toCoseKey(), keyContext!!.toByteArray()) response = communicateE2EE(request0.toCbor()) val response0 = CloudSecureAreaProtocol.Command.fromCbor(response) as KeyAgreementResponse0 val dataToSignLocally = Cbor.encode( @@ -721,6 +759,7 @@ open class CloudSecureArea( ) val localKeyAlias = getLocalKeyAlias(alias) val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val deviceBindingKeyEntry = ks.getEntry(localKeyAlias, null) ?: throw KeyInvalidatedException("This key is no longer available") @@ -762,9 +801,7 @@ open class CloudSecureArea( } CloudSecureAreaProtocol.RESULT_TOO_MANY_PASSPHRASE_ATTEMPTS -> { - runBlocking { - delayForBruteforceMitigation(response1.waitDurationMillis.milliseconds) - } + delayForBruteforceMitigation(response1.waitDurationMillis.milliseconds) tryAgain = true } @@ -807,10 +844,10 @@ open class CloudSecureArea( return Zab!! } - override fun getKeyInfo(alias: String): CloudKeyInfo { - val data = storageEngine[getStoragePrefixForKeyInfo(alias)] + override suspend fun getKeyInfo(alias: String): CloudKeyInfo { + val data = storageTable.get(key = METADATA_PREFIX + alias, partitionId = identifier) ?: throw IllegalArgumentException("No key with given alias") - val map = Cbor.decode(data) + val map = Cbor.decode(data.toByteArray()) val keyPurposes = map["keyPurposes"].asNumber val userAuthenticationRequired = map["userAuthenticationRequired"].asBoolean val userAuthenticationTimeoutMillis = map["userAuthenticationTimeoutMillis"].asNumber @@ -827,6 +864,7 @@ open class CloudSecureArea( val attestationCertChain = map["attestationCertChain"].asX509CertChain val userAuthenticationType = map["userAuthenticationType"].asNumber return CloudKeyInfo( + alias, KeyAttestation(attestationCertChain.certificates[0].ecPublicKey, attestationCertChain), KeyPurpose.decodeSet(keyPurposes), userAuthenticationRequired, @@ -839,8 +877,9 @@ open class CloudSecureArea( ) } - override fun getKeyInvalidated(alias: String): Boolean { + override suspend fun getKeyInvalidated(alias: String): Boolean { val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) // If the LSKF is removed, all auth-bound keys are removed and the result is // that KeyStore.getEntry() returns null. @@ -848,7 +887,7 @@ open class CloudSecureArea( return ks.getEntry(localKeyAlias, null) == null } - private fun saveKeyMetadata( + private suspend fun saveKeyMetadata( alias: String, settings: CloudCreateKeySettings, attestationCertChain: X509CertChain @@ -870,29 +909,53 @@ open class CloudSecureArea( map.put("isPassphraseRequired", settings.passphraseRequired) map.put("useStrongBox", settings.useStrongBox) map.put("attestationCertChain", attestationCertChain.toDataItem()) - storageEngine.put(getStoragePrefixForKeyInfo(alias), Cbor.encode(map.end().build())) + storageTable.insert( + key = METADATA_PREFIX + alias, + partitionId = identifier, + data = ByteString(Cbor.encode(map.end().build())) + ) } - // Returns the prefix used in Android Keystore aliases for local keys - // to avoid conflicts with regular Android Keystore keys and multiple instances - // of the [CloudSecureArea]. internal fun getLocalKeyAlias(alias: String): String { return "${identifier}_alias_${alias}" } - // Returns the prefix used for storing key-info for a cloud-based key. - private fun getStoragePrefixForKeyInfo(alias: String): String { - return "${identifier}_KeyInfo_${alias}" - } - - // Returns the prefix used for storing server state for a cloud-based key. - private fun getStoragePrefixForPerKeyServerState(alias: String): String { - return "${identifier}_ServerState_${alias}" - } - companion object { const val IDENTIFIER_PREFIX = "CloudSecureArea" private const val TAG = "CloudSecureArea" + + // Special keys in storage + private const val BINDING_KEY = "[BindingKey]" + private const val REGISTRATION_CONTEXT = "[RegistrationContext]" + private const val PASSPHRASE_CONSTRAINTS = "[PassphraseConstraints]" + + // Prefix for metadata storage key + private const val METADATA_PREFIX = "@" + + /** + * Creates an instance of [CloudSecureArea]. + * + * @param storage the storage engine to use for storing key material. + */ + suspend fun create( + storage: Storage, + identifier: String, + serverUrl: String + ): CloudSecureArea { + val secureArea = CloudSecureArea( + storage.getTable(tableSpec), + identifier, + serverUrl + ) + secureArea.initialize() + return secureArea + } + + private val tableSpec = StorageTableSpec( + name = "CloudSecureArea", + supportPartitions = true, + supportExpiration = false + ) } } \ No newline at end of file diff --git a/identity-android-legacy/build.gradle.kts b/identity-android-legacy/build.gradle.kts index 3154c0dbd..d1545ac05 100644 --- a/identity-android-legacy/build.gradle.kts +++ b/identity-android-legacy/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.kotlinx.coroutines.core) } group = "com.android.identity" diff --git a/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java b/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java deleted file mode 100644 index d95df0ccc..000000000 --- a/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java +++ /dev/null @@ -1,177 +0,0 @@ -/* - * Copyright 2023 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.identity.android.legacy; - -import android.content.Context; - -import com.android.identity.android.securearea.AndroidKeystoreKeyInfo; -import com.android.identity.android.securearea.AndroidKeystoreSecureArea; -import com.android.identity.android.storage.AndroidStorageEngine; -import com.android.identity.cbor.Cbor; -import com.android.identity.cbor.DiagnosticOption; -import com.android.identity.crypto.Crypto; -import com.android.identity.crypto.EcPublicKey; -import com.android.identity.crypto.EcPublicKeyJvmKt; -import com.android.identity.crypto.EcSignature; -import com.android.identity.document.NameSpacedData; -import com.android.identity.crypto.Algorithm; -import com.android.identity.crypto.EcCurve; -import com.android.identity.securearea.KeyAttestation; -import com.android.identity.securearea.KeyPurpose; -import com.android.identity.storage.StorageEngine; - -import org.junit.Assert; -import org.junit.Test; - -import java.io.File; -import java.nio.charset.StandardCharsets; -import java.security.PublicKey; -import java.security.cert.X509Certificate; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Set; - -import co.nstant.in.cbor.CborBuilder; -import co.nstant.in.cbor.model.UnicodeString; -import kotlinx.io.files.Path; - -@SuppressWarnings("deprecation") -public class MigrateFromKeystoreICStoreTest { - private static final String MDL_DOCTYPE = "org.iso.18013.5.1.mDL"; - private static final String MDL_NAMESPACE = "org.iso.18013.5.1"; - private static final String AAMVA_NAMESPACE = "org.iso.18013.5.1.aamva"; - private static final String TEST_NAMESPACE = "org.example.test"; - - // The two methods that can be used to migrate a credential from KeystoreIdentityCredentialStore - // to CredentialStore are getNamedSpacedData() and getCredentialKey(). This test checks that - // they work as expected.. - // - @Test - public void testMigrateToCredentialStore() throws Exception { - Context context = androidx.test.InstrumentationRegistry.getTargetContext(); - Path storageFile = new Path(new File(context.getDataDir(), "testdata.bin")); - StorageEngine storageEngine = new AndroidStorageEngine.Builder(context, storageFile).build(); - AndroidKeystoreSecureArea aksSecureArea = new AndroidKeystoreSecureArea(context, storageEngine); - IdentityCredentialStore icStore = Utility.getIdentityCredentialStore(context); - - AccessControlProfile noAuthProfile = - new AccessControlProfile.Builder(new AccessControlProfileId(0)) - .setUserAuthenticationRequired(false) - .build(); - Collection ids = new ArrayList(); - ids.add(new AccessControlProfileId(0)); - - byte[] encodedDrivingPrivileges = Util.cborEncode( - new CborBuilder() - .addArray() - .addMap() - .put(new UnicodeString("vehicle_category_code"), new UnicodeString("A")) - .end() - .end() - .build().get(0)); - - PersonalizationData personalizationData = - new PersonalizationData.Builder() - .addAccessControlProfile(noAuthProfile) - .putEntry(MDL_NAMESPACE, "given_name", ids, Util.cborEncodeString("Erika")) - .putEntry(MDL_NAMESPACE, "family_name", ids, Util.cborEncodeString("Mustermann")) - .putEntry(MDL_NAMESPACE, "resident_address", ids, Util.cborEncodeString("Germany")) - .putEntry(MDL_NAMESPACE, "portrait", ids, Util.cborEncodeBytestring(new byte[]{0x01, 0x02})) - .putEntry(MDL_NAMESPACE, "height", ids, Util.cborEncodeNumber(180)) - .putEntry(MDL_NAMESPACE, "driving_privileges", ids, encodedDrivingPrivileges) - .putEntry(AAMVA_NAMESPACE, "weight_range", ids, Util.cborEncodeNumber(5)) - .putEntry(TEST_NAMESPACE, "neg_int", ids, Util.cborEncodeNumber(-42)) - .putEntry(TEST_NAMESPACE, "int_16", ids, Util.cborEncodeNumber(0x101)) - .putEntry(TEST_NAMESPACE, "int_32", ids, Util.cborEncodeNumber(0x10001)) - .putEntry(TEST_NAMESPACE, "int_64", ids, Util.cborEncodeNumber(0x100000001L)) - .build(); - String credName = "test"; - icStore.deleteCredentialByName(credName); - WritableIdentityCredential wc = icStore.createCredential(credName, MDL_DOCTYPE); - Collection wcCertChain = wc.getCredentialKeyCertificateChain("".getBytes(StandardCharsets.UTF_8)); - PublicKey credentialKeyPublic = wcCertChain.iterator().next().getPublicKey(); - wc.personalize(personalizationData); - - KeystoreIdentityCredential cred = (KeystoreIdentityCredential) icStore.getCredentialByName( - credName, - IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); - Assert.assertNotNull(cred); - - // Get and check NameSpacedData - NameSpacedData nsd = cred.getNameSpacedData(); - Assert.assertEquals( - "{\n" + - " \"org.iso.18013.5.1\": {\n" + - " \"given_name\": 24(<< \"Erika\" >>),\n" + - " \"family_name\": 24(<< \"Mustermann\" >>),\n" + - " \"resident_address\": 24(<< \"Germany\" >>),\n" + - " \"portrait\": 24(<< h'0102' >>),\n" + - " \"height\": 24(<< 180 >>),\n" + - " \"driving_privileges\": 24(<< [\n" + - " {\n" + - " \"vehicle_category_code\": \"A\"\n" + - " }\n" + - " ] >>)\n" + - " },\n" + - " \"org.iso.18013.5.1.aamva\": {\n" + - " \"weight_range\": 24(<< 5 >>)\n" + - " },\n" + - " \"org.example.test\": {\n" + - " \"neg_int\": 24(<< -42 >>),\n" + - " \"int_16\": 24(<< 257 >>),\n" + - " \"int_32\": 24(<< 65537 >>),\n" + - " \"int_64\": 24(<< 4294967297 >>)\n" + - " }\n" + - "}", - Cbor.INSTANCE.toDiagnostics( - nsd.encodeAsCbor(), - Set.of(DiagnosticOption.EMBEDDED_CBOR, DiagnosticOption.PRETTY_PRINT))); - - String credentialKeyAlias = cred.getCredentialKeyAlias(); - aksSecureArea.createKeyForExistingAlias(credentialKeyAlias); - - // Check that CrendentialKey's KeyInfo is correct - AndroidKeystoreKeyInfo keyInfo = aksSecureArea.getKeyInfo(credentialKeyAlias); - Assert.assertNotNull(keyInfo); - KeyAttestation attestation = keyInfo.getAttestation(); - Assert.assertTrue(attestation.getCertChain().getCertificates().size() >= 1); - Assert.assertEquals(Set.of(KeyPurpose.SIGN), keyInfo.getKeyPurposes()); - Assert.assertEquals(EcCurve.P256, keyInfo.getPublicKey().getCurve()); - Assert.assertFalse(keyInfo.isStrongBoxBacked()); - Assert.assertFalse(keyInfo.isUserAuthenticationRequired()); - Assert.assertEquals(0, keyInfo.getUserAuthenticationTimeoutMillis()); - Assert.assertEquals(Set.of(), keyInfo.getUserAuthenticationTypes()); - Assert.assertNull(keyInfo.getAttestKeyAlias()); - Assert.assertNull(keyInfo.getValidFrom()); - Assert.assertNull(keyInfo.getValidUntil()); - - // Check that we can use CredentialKey via AndroidKeystoreSecureArea... - byte[] dataToSign = new byte[]{1, 2, 3}; - EcSignature ecSignature; - ecSignature = aksSecureArea.sign( - credentialKeyAlias, - Algorithm.ES256, - dataToSign, - null); - EcPublicKey ecCredentialKeyPublic = EcPublicKeyJvmKt.toEcPublicKey(credentialKeyPublic, EcCurve.P256); - Assert.assertTrue(Crypto.INSTANCE.checkSignature( - ecCredentialKeyPublic, - dataToSign, - Algorithm.ES256, - ecSignature)); - } -} diff --git a/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.kt b/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.kt new file mode 100644 index 000000000..afa8289b8 --- /dev/null +++ b/identity-android-legacy/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.identity.android.legacy + +import androidx.test.InstrumentationRegistry +import co.nstant.`in`.cbor.CborBuilder +import co.nstant.`in`.cbor.model.UnicodeString +import com.android.identity.android.legacy.Util.cborEncode +import com.android.identity.android.legacy.Util.cborEncodeBytestring +import com.android.identity.android.legacy.Util.cborEncodeNumber +import com.android.identity.android.legacy.Util.cborEncodeString +import com.android.identity.android.securearea.AndroidKeystoreSecureArea +import com.android.identity.android.storage.AndroidStorageEngine +import com.android.identity.cbor.Cbor.toDiagnostics +import com.android.identity.cbor.DiagnosticOption +import com.android.identity.crypto.Algorithm +import com.android.identity.crypto.Crypto.checkSignature +import com.android.identity.crypto.EcCurve +import com.android.identity.crypto.toEcPublicKey +import com.android.identity.securearea.KeyPurpose +import com.android.identity.storage.StorageEngine +import com.android.identity.storage.android.AndroidStorage +import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import org.junit.Assert +import org.junit.Test +import java.io.File +import java.nio.charset.StandardCharsets + +@Suppress("deprecation") +class MigrateFromKeystoreICStoreTest { + // The two methods that can be used to migrate a credential from KeystoreIdentityCredentialStore + // to CredentialStore are getNamedSpacedData() and getCredentialKey(). This test checks that + // they work as expected.. + // + @Test + @Throws(Exception::class) + fun testMigrateToCredentialStore() = runBlocking { + val context = InstrumentationRegistry.getTargetContext() + val storage = AndroidStorage(":memory:") + val aksSecureArea = AndroidKeystoreSecureArea.create(context, storage) + val icStore = Utility.getIdentityCredentialStore(context) + + val noAuthProfile = + AccessControlProfile.Builder(AccessControlProfileId(0)) + .setUserAuthenticationRequired(false) + .build() + val ids: MutableCollection = ArrayList() + ids.add(AccessControlProfileId(0)) + + val encodedDrivingPrivileges = cborEncode( + CborBuilder() + .addArray() + .addMap() + .put( + UnicodeString("vehicle_category_code"), + UnicodeString("A") + ) + .end() + .end() + .build()[0] + ) + + val personalizationData = + PersonalizationData.Builder() + .addAccessControlProfile(noAuthProfile) + .putEntry(MDL_NAMESPACE, "given_name", ids, cborEncodeString("Erika")) + .putEntry(MDL_NAMESPACE, "family_name", ids, cborEncodeString("Mustermann")) + .putEntry(MDL_NAMESPACE, "resident_address", ids, cborEncodeString("Germany")) + .putEntry( + MDL_NAMESPACE, + "portrait", + ids, + cborEncodeBytestring(byteArrayOf(0x01, 0x02)) + ) + .putEntry(MDL_NAMESPACE, "height", ids, cborEncodeNumber(180)) + .putEntry(MDL_NAMESPACE, "driving_privileges", ids, encodedDrivingPrivileges) + .putEntry(AAMVA_NAMESPACE, "weight_range", ids, cborEncodeNumber(5)) + .putEntry(TEST_NAMESPACE, "neg_int", ids, cborEncodeNumber(-42)) + .putEntry(TEST_NAMESPACE, "int_16", ids, cborEncodeNumber(0x101)) + .putEntry(TEST_NAMESPACE, "int_32", ids, cborEncodeNumber(0x10001)) + .putEntry(TEST_NAMESPACE, "int_64", ids, cborEncodeNumber(0x100000001L)) + .build() + val credName = "test" + icStore.deleteCredentialByName(credName) + val wc = icStore.createCredential(credName, MDL_DOCTYPE) + val wcCertChain = + wc.getCredentialKeyCertificateChain("".toByteArray(StandardCharsets.UTF_8)) + val credentialKeyPublic = wcCertChain.iterator().next().publicKey + wc.personalize(personalizationData) + + val cred = icStore.getCredentialByName( + credName, + IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256 + ) as KeystoreIdentityCredential? + Assert.assertNotNull(cred) + + // Get and check NameSpacedData + val nsd = cred!!.nameSpacedData + Assert.assertEquals( + """{ + "org.iso.18013.5.1": { + "given_name": 24(<< "Erika" >>), + "family_name": 24(<< "Mustermann" >>), + "resident_address": 24(<< "Germany" >>), + "portrait": 24(<< h'0102' >>), + "height": 24(<< 180 >>), + "driving_privileges": 24(<< [ + { + "vehicle_category_code": "A" + } + ] >>) + }, + "org.iso.18013.5.1.aamva": { + "weight_range": 24(<< 5 >>) + }, + "org.example.test": { + "neg_int": 24(<< -42 >>), + "int_16": 24(<< 257 >>), + "int_32": 24(<< 65537 >>), + "int_64": 24(<< 4294967297 >>) + } +}""", + toDiagnostics( + nsd.encodeAsCbor(), + setOf(DiagnosticOption.EMBEDDED_CBOR, DiagnosticOption.PRETTY_PRINT) + ) + ) + + val credentialKeyAlias = cred.credentialKeyAlias + aksSecureArea.createKeyForExistingAlias(credentialKeyAlias) + + // Check that CrendentialKey's KeyInfo is correct + val keyInfo = aksSecureArea.getKeyInfo(credentialKeyAlias) + Assert.assertNotNull(keyInfo) + val attestation = keyInfo.attestation + Assert.assertTrue(attestation.certChain!!.certificates.size >= 1) + Assert.assertEquals(setOf(KeyPurpose.SIGN), keyInfo.keyPurposes) + Assert.assertEquals(EcCurve.P256, keyInfo.publicKey.curve) + Assert.assertFalse(keyInfo.isStrongBoxBacked) + Assert.assertFalse(keyInfo.isUserAuthenticationRequired) + Assert.assertEquals(0, keyInfo.userAuthenticationTimeoutMillis) + Assert.assertEquals(setOf(), keyInfo.userAuthenticationTypes) + Assert.assertNull(keyInfo.attestKeyAlias) + Assert.assertNull(keyInfo.validFrom) + Assert.assertNull(keyInfo.validUntil) + + // Check that we can use CredentialKey via AndroidKeystoreSecureArea... + val dataToSign = byteArrayOf(1, 2, 3) + val ecSignature = aksSecureArea.sign( + credentialKeyAlias, + Algorithm.ES256, + dataToSign, + null + ) + val ecCredentialKeyPublic = credentialKeyPublic.toEcPublicKey(EcCurve.P256) + Assert.assertTrue( + checkSignature( + ecCredentialKeyPublic, + dataToSign, + Algorithm.ES256, + ecSignature + ) + ) + } + + companion object { + private const val MDL_DOCTYPE = "org.iso.18013.5.1.mDL" + private const val MDL_NAMESPACE = "org.iso.18013.5.1" + private const val AAMVA_NAMESPACE = "org.iso.18013.5.1.aamva" + private const val TEST_NAMESPACE = "org.example.test" + } +} diff --git a/identity-android-legacy/src/main/java/com/android/identity/android/legacy/Util.kt b/identity-android-legacy/src/main/java/com/android/identity/android/legacy/Util.kt index 48fbadd8a..0359ffb0b 100644 --- a/identity-android-legacy/src/main/java/com/android/identity/android/legacy/Util.kt +++ b/identity-android-legacy/src/main/java/com/android/identity/android/legacy/Util.kt @@ -36,11 +36,7 @@ import co.nstant.`in`.cbor.model.Special import co.nstant.`in`.cbor.model.SpecialType import co.nstant.`in`.cbor.model.UnicodeString import co.nstant.`in`.cbor.model.UnsignedInteger -import com.android.identity.crypto.Algorithm import com.android.identity.crypto.EcCurve -import com.android.identity.securearea.KeyLockedException -import com.android.identity.securearea.KeyUnlockData -import com.android.identity.securearea.SecureArea import com.android.identity.util.Logger.w import org.bouncycastle.asn1.ASN1Encodable import org.bouncycastle.asn1.ASN1InputStream @@ -573,61 +569,6 @@ object Util { return builder.build().first() } - @JvmStatic - @Throws(KeyLockedException::class) - fun coseSign1Sign( - secureArea: SecureArea, - alias: String, - signatureAlgorithm: Algorithm, - keyUnlockData: KeyUnlockData?, - data: ByteArray?, - detachedContent: ByteArray?, - certificateChain: Collection? - ): DataItem { - val dataLen = data?.size ?: 0 - val detachedContentLen = detachedContent?.size ?: 0 - require(!(dataLen > 0 && detachedContentLen > 0)) { "data and detachedContent cannot both be non-empty" } - val protectedHeaders = CborBuilder() - val protectedHeadersMap: MapBuilder = protectedHeaders.addMap() - protectedHeadersMap.put( - COSE_LABEL_ALG, - signatureAlgorithm.coseAlgorithmIdentifier.toLong() - ) - val protectedHeadersBytes = cborEncode(protectedHeaders.build().first()) - val toBeSigned = coseBuildToBeSigned(protectedHeadersBytes, data, detachedContent) - val signature = secureArea.sign(alias, signatureAlgorithm, toBeSigned, keyUnlockData) - val coseSignature = signature.toCoseEncoded() - val builder = CborBuilder() - val array: ArrayBuilder = builder.addArray() - array.add(protectedHeadersBytes) - val unprotectedHeaders: MapBuilder> = array.addMap() - try { - if (!certificateChain.isNullOrEmpty()) { - if (certificateChain.size == 1) { - val cert = certificateChain.iterator().next() - unprotectedHeaders.put(COSE_LABEL_X5CHAIN, cert.encoded) - } else { - val x5chainsArray: ArrayBuilder>> = - unprotectedHeaders.putArray( - COSE_LABEL_X5CHAIN - ) - for (cert in certificateChain) { - x5chainsArray.add(cert.encoded) - } - } - } - } catch (e: CertificateEncodingException) { - throw IllegalStateException("Error encoding certificate", e) - } - if (data == null || data.isEmpty()) { - array.add(SimpleValue(SimpleValueType.NULL)) - } else { - array.add(data) - } - array.add(coseSignature) - return builder.build().first() - } - /** * Note: this uses the default JCA provider which may not support a lot of curves, for * example it doesn't support Brainpool curves. If you need to use such curves, use diff --git a/identity-android/build.gradle.kts b/identity-android/build.gradle.kts index d83ccb66a..da37448b1 100644 --- a/identity-android/build.gradle.kts +++ b/identity-android/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.io.core) implementation(libs.kotlinx.io.bytestring) + implementation(libs.kotlinx.coroutines.core) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.junit) diff --git a/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt b/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt index f30ace211..1ac0f7402 100644 --- a/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt +++ b/identity-android/src/androidTest/java/com/android/identity/android/document/AndroidKeystoreSecureAreaDocumentStoreTest.kt @@ -19,18 +19,15 @@ import androidx.test.InstrumentationRegistry import com.android.identity.android.TestUtil import com.android.identity.android.securearea.AndroidKeystoreCreateKeySettings import com.android.identity.android.securearea.AndroidKeystoreSecureArea -import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.credential.CredentialFactory import com.android.identity.credential.SecureAreaBoundCredential import com.android.identity.document.Document import com.android.identity.document.DocumentStore -import com.android.identity.crypto.javaX509Certificate -import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity.util.AndroidAttestationExtensionParser -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem +import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Before import org.junit.Test @@ -43,30 +40,26 @@ class AndroidKeystoreSecureAreaDocumentStoreTest { private const val CREDENTIAL_DOMAIN = "domain" } - private lateinit var storageEngine: StorageEngine - private lateinit var secureArea: SecureArea + private lateinit var storage: Storage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var credentialFactory: CredentialFactory @Before fun setup() { - val context = InstrumentationRegistry.getTargetContext() - val storageFile = Path(context.dataDir.path, "testdata.bin") - SystemFileSystem.delete(storageFile, false) - storageEngine = AndroidStorageEngine.Builder(context, storageFile).build() - secureAreaRepository = SecureAreaRepository() - secureArea = AndroidKeystoreSecureArea(context, storageEngine) - secureAreaRepository.addImplementation(secureArea) + storage = AndroidStorage(":memory:") + secureAreaRepository = SecureAreaRepository.build { + add(AndroidKeystoreSecureArea.create(context, storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(SecureAreaBoundCredential::class) { - document, dataItem -> SecureAreaBoundCredential(document, dataItem) + document, dataItem -> SecureAreaBoundCredential(document).apply { deserialize(dataItem) } } } @Test - fun testBasic() { - val documentStore = DocumentStore(storageEngine, secureAreaRepository, credentialFactory) + fun testBasic() = runBlocking { + val documentStore = DocumentStore(storage, secureAreaRepository, credentialFactory) var document: Document? = documentStore.createDocument( "testDocument" ) @@ -75,15 +68,18 @@ class AndroidKeystoreSecureAreaDocumentStoreTest { // Create pending credential and check its attestation val authKeyChallenge = byteArrayOf(20, 21, 22) + val secureArea = + secureAreaRepository.getImplementation(AndroidKeystoreSecureArea.IDENTIFIER) val pendingCredential = SecureAreaBoundCredential( document, null, CREDENTIAL_DOMAIN, - secureArea, - AndroidKeystoreCreateKeySettings.Builder(authKeyChallenge).build(), - ) + secureArea!! + ).apply { + generateKey(AndroidKeystoreCreateKeySettings.Builder(authKeyChallenge).build()) + } Assert.assertFalse(pendingCredential.isCertified) - val attestation = pendingCredential.attestation + val attestation = pendingCredential.getAttestation() val parser = AndroidAttestationExtensionParser(attestation.certChain!!.certificates[0]) Assert.assertArrayEquals( diff --git a/identity-android/src/androidTest/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelperTest.kt b/identity-android/src/androidTest/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelperTest.kt index 3e241170c..d0e057117 100644 --- a/identity-android/src/androidTest/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelperTest.kt +++ b/identity-android/src/androidTest/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelperTest.kt @@ -21,6 +21,7 @@ import com.android.identity.android.mdoc.engagement.QrEngagementHelper import com.android.identity.android.mdoc.transport.DataTransport import com.android.identity.android.mdoc.transport.DataTransportOptions import com.android.identity.android.mdoc.transport.DataTransportTcp +import com.android.identity.android.securearea.AndroidKeystoreSecureArea import com.android.identity.asn1.ASN1Integer import com.android.identity.cbor.Bstr import com.android.identity.cbor.Cbor.encode @@ -64,13 +65,12 @@ import com.android.identity.mdoc.util.MdocUtil.mergeIssuerNamesSpaces import com.android.identity.mdoc.util.MdocUtil.stripIssuerNameSpaces import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose -import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareCreateKeySettings -import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity.util.Constants +import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock.System.now import kotlinx.datetime.Instant import kotlinx.datetime.Instant.Companion.fromEpochMilliseconds @@ -95,8 +95,7 @@ class DeviceRetrievalHelperTest { private const val AAMVA_NAMESPACE = "org.aamva.18013.5.1" } - private lateinit var storageEngine: StorageEngine - private lateinit var secureArea: SecureArea + private lateinit var storage: Storage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var document: Document private lateinit var mdocCredential: MdocCredential @@ -105,28 +104,36 @@ class DeviceRetrievalHelperTest { private lateinit var timeValidityEnd: Instant private lateinit var documentSignerKey: EcPrivateKey private lateinit var documentSignerCert: X509Cert - + private lateinit var documentStore: DocumentStore + @Before fun setUp() { // This is needed to prefer BouncyCastle bundled with the app instead of the Conscrypt // based implementation included in Android. Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) Security.addProvider(BouncyCastleProvider()) - - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) - var credentialFactory = CredentialFactory() - credentialFactory.addCredentialImplementation(MdocCredential::class) { - document, dataItem -> MdocCredential(document, dataItem) + + storage = AndroidStorage(":memory:") + secureAreaRepository = SecureAreaRepository.build { + add( + AndroidKeystoreSecureArea.create( + InstrumentationRegistry.getTargetContext(), + storage + ) + ) + } + val credentialFactory = CredentialFactory() + credentialFactory.addCredentialImplementation(MdocCredential::class) { document, dataItem -> + MdocCredential(document).apply { deserialize(dataItem) } } - val documentStore = DocumentStore( - storageEngine, + documentStore = DocumentStore( + storage, secureAreaRepository, credentialFactory ) + } + private suspend fun asyncSetup() { // Create the document... document = documentStore.createDocument("testDocument") documentStore.addDocument(document) @@ -143,23 +150,28 @@ class DeviceRetrievalHelperTest { timeSigned = fromEpochMilliseconds(nowMillis) timeValidityBegin = fromEpochMilliseconds(nowMillis + 3600 * 1000) timeValidityEnd = fromEpochMilliseconds(nowMillis + 10 * 86400 * 1000) + val secureArea = + secureAreaRepository.getImplementation(AndroidKeystoreSecureArea.IDENTIFIER) mdocCredential = MdocCredential( document, null, CREDENTIAL_DOMAIN, - secureArea, - SoftwareCreateKeySettings.Builder() - .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) - .build(), + secureArea!!, MDL_DOCTYPE - ) + ).apply { + generateKey( + SoftwareCreateKeySettings.Builder() + .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) + .build() + ) + } Assert.assertFalse(mdocCredential.isCertified) // Generate an MSO and issuer-signed data for this credential. val msoGenerator = MobileSecurityObjectGenerator( "SHA-256", MDL_DOCTYPE, - mdocCredential.attestation.publicKey + mdocCredential.getAttestation().publicKey ) msoGenerator.setValidityInfo(timeSigned, timeValidityBegin, timeValidityEnd, null) val issuerNameSpaces = generateIssuerNameSpaces( @@ -231,7 +243,8 @@ class DeviceRetrievalHelperTest { } @Test - fun testPresentation() { + fun testPresentation() = runBlocking { + asyncSetup() val context = InstrumentationRegistry.getTargetContext() val condVarDeviceConnected = ConditionVariable() val condVarDeviceDisconnected = ConditionVariable() @@ -365,7 +378,7 @@ class DeviceRetrievalHelperTest { val presentation = arrayOf(null) val listener: DeviceRetrievalHelper.Listener = object : DeviceRetrievalHelper.Listener { override fun onEReaderKeyReceived(eReaderKey: EcPublicKey) {} - override fun onDeviceRequest(deviceRequestBytes: ByteArray) { + override fun onDeviceRequest(deviceRequestBytes: ByteArray) = runBlocking { val parser = DeviceRequestParser( deviceRequestBytes, presentation[0]!!.sessionTranscript @@ -412,6 +425,7 @@ class DeviceRetrievalHelperTest { ) .generate() ) + presentation[0]!!.sendDeviceResponse(generator.generate(), null) } catch (e: KeyLockedException) { throw AssertionError(e) @@ -445,7 +459,8 @@ class DeviceRetrievalHelperTest { @Test @Throws(Exception::class) - fun testPresentationVerifierDisconnects() { + fun testPresentationVerifierDisconnects() = runBlocking { + asyncSetup() val context = InstrumentationRegistry.getTargetContext() val executor: Executor = Executors.newSingleThreadExecutor() val condVarDeviceConnected = ConditionVariable() diff --git a/identity-android/src/main/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelper.kt b/identity-android/src/main/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelper.kt index ff2962fb3..67879fcf1 100644 --- a/identity-android/src/main/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelper.kt +++ b/identity-android/src/main/java/com/android/identity/android/mdoc/deviceretrieval/DeviceRetrievalHelper.kt @@ -32,6 +32,9 @@ import com.android.identity.mdoc.origininfo.OriginInfo import com.android.identity.mdoc.sessionencryption.SessionEncryption import com.android.identity.util.Constants import com.android.identity.util.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.launch import java.util.concurrent.Executor /** @@ -68,7 +71,7 @@ class DeviceRetrievalHelper internal constructor( private var reverseEngagementReaderEngagement: ByteArray? = null private var reverseEngagementOriginInfos: List? = null private var reverseEngagementEncodedEReaderKey: ByteArray? = null - + private val listenerCoroutineScope = CoroutineScope(listenerExecutor.asCoroutineDispatcher()) /** * The bytes of the device engagement being used. */ @@ -113,7 +116,7 @@ class DeviceRetrievalHelper internal constructor( // Note: The report*() methods are safe to call from any thread. fun reportEReaderKeyReceived(eReaderKey: EcPublicKey) { Logger.d(TAG, "reportEReaderKeyReceived: $eReaderKey") - listenerExecutor.execute { + listenerCoroutineScope.launch { if (!inhibitCallbacks) { listener.onEReaderKeyReceived(eReaderKey) } @@ -122,7 +125,7 @@ class DeviceRetrievalHelper internal constructor( fun reportDeviceRequest(deviceRequestBytes: ByteArray) { Logger.d(TAG, "reportDeviceRequest: deviceRequestBytes: ${deviceRequestBytes.size} bytes") - listenerExecutor.execute { + listenerCoroutineScope.launch { if (!inhibitCallbacks) { listener.onDeviceRequest(deviceRequestBytes) } @@ -132,7 +135,7 @@ class DeviceRetrievalHelper internal constructor( fun reportDeviceDisconnected(transportSpecificTermination: Boolean) { Logger.d(TAG, "reportDeviceDisconnected: transportSpecificTermination: " + "$transportSpecificTermination") - listenerExecutor.execute { + listenerCoroutineScope.launch { if (!inhibitCallbacks) { listener.onDeviceDisconnected(transportSpecificTermination) } @@ -141,7 +144,7 @@ class DeviceRetrievalHelper internal constructor( fun reportError(error: Throwable) { Logger.d(TAG, "reportError: error: ", error) - listenerExecutor.execute { + listenerCoroutineScope.launch { if (!inhibitCallbacks) { listener.onError(error) } diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/PresentmentSource.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/PresentmentSource.kt index cda04318e..cd146a13b 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/PresentmentSource.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/PresentmentSource.kt @@ -39,7 +39,7 @@ interface PresentmentSource { * @param preSelectedDocument if not `null`, a [Document] preselected by the user. * @return zero, one, or more [Credential] instances eligible for presentment. */ - fun selectCredentialForPresentment( + suspend fun selectCredentialForPresentment( request: Request, preSelectedDocument: Document? ): List diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/digitalCredentialsPresentment.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/digitalCredentialsPresentment.kt index 30a0276f9..7fdb9cca2 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/digitalCredentialsPresentment.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/digitalCredentialsPresentment.kt @@ -339,7 +339,7 @@ private suspend fun digitalCredentialsArfProtocol( presentmentModel.setCompleted() } -private fun calcDocument( +private suspend fun calcDocument( credential: MdocCredential, claims: List, encodedSessionTranscript: ByteArray diff --git a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/mdocPresentment.kt b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/mdocPresentment.kt index 55f1b82bb..5fc04c283 100644 --- a/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/mdocPresentment.kt +++ b/identity-appsupport/src/commonMain/kotlin/com/android/identity/appsupport/ui/presentment/mdocPresentment.kt @@ -181,7 +181,7 @@ internal suspend fun mdocPresentment( } } -private fun calcDocument( +private suspend fun calcDocument( credential: MdocCredential, claims: List, encodedSessionTranscript: ByteArray diff --git a/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt b/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt index 0863072ff..7d1f7ca1f 100644 --- a/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt +++ b/identity-csa/src/main/java/com/android/identity/securearea/cloud/CloudSecureAreaServer.kt @@ -28,7 +28,7 @@ import com.android.identity.securearea.cloud.CloudSecureAreaProtocol.RegisterRes import com.android.identity.securearea.cloud.CloudSecureAreaProtocol.RegisterResponse1 import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage import com.android.identity.util.AndroidAttestationExtensionParser import com.android.identity.util.Logger import com.android.identity.util.fromHex @@ -38,6 +38,7 @@ import kotlinx.datetime.DateTimePeriod import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.plus +import kotlinx.io.bytestring.ByteString import java.io.IOException import java.nio.ByteBuffer import java.security.InvalidKeyException @@ -445,7 +446,7 @@ class CloudSecureAreaServer( return Pair(200, encryptedResponse0.toCbor()) } - private fun doCreateKeyRequest1( + private suspend fun doCreateKeyRequest1( request1: CloudSecureAreaProtocol.CreateKeyRequest1, remoteHost: String, e2eeState: E2EEState @@ -461,8 +462,8 @@ class CloudSecureAreaServer( throw IllegalStateException("Error verifying signature") } state.localKey = request1.localKeyAttestation.certificates.get(0).ecPublicKey.toCoseKey() - val storageEngine = EphemeralStorageEngine() - val secureArea = SoftwareSecureArea(storageEngine) + val storage = EphemeralStorage() + val secureArea = SoftwareSecureArea.create(storage) val builder = SoftwareCreateKeySettings.Builder() .setValidityPeriod( Instant.fromEpochMilliseconds(state.validFromMillis), @@ -493,7 +494,7 @@ class CloudSecureAreaServer( ) .build() - state.cloudKeyStorage = storageEngine.toCbor() + state.cloudKeyStorage = storage.serialize().toByteArray() val response1 = CreateKeyResponse1( X509CertChain(listOf(attestationCert) + attestationKeyCertification.certificates), encryptCreateKeyState(state) @@ -544,7 +545,7 @@ class CloudSecureAreaServer( return Pair(200, encryptedResponse0.toCbor()) } - private fun doSignRequest1( + private suspend fun doSignRequest1( request1: CloudSecureAreaProtocol.SignRequest1, remoteHost: String, e2eeState: E2EEState @@ -604,8 +605,8 @@ class CloudSecureAreaServer( } } - val storageEngine = EphemeralStorageEngine.fromCbor(state.keyContext!!.cloudKeyStorage!!) - val secureArea = SoftwareSecureArea(storageEngine) + val storage = EphemeralStorage.deserialize(ByteString(state.keyContext!!.cloudKeyStorage!!)) + val secureArea = SoftwareSecureArea.create(storage) val signature = secureArea.sign( "CloudKey", Algorithm.ES256, @@ -693,7 +694,7 @@ class CloudSecureAreaServer( return Pair(200, encryptedResponse0.toCbor()) } - private fun doKeyAgreementRequest1( + private suspend fun doKeyAgreementRequest1( request1: CloudSecureAreaProtocol.KeyAgreementRequest1, remoteHost: String, e2eeState: E2EEState @@ -755,8 +756,9 @@ class CloudSecureAreaServer( } - val storageEngine = EphemeralStorageEngine.fromCbor(state.keyContext!!.cloudKeyStorage!!) - val secureArea = SoftwareSecureArea(storageEngine) + val storage = EphemeralStorage.deserialize( + ByteString(state.keyContext!!.cloudKeyStorage!!)) + val secureArea = SoftwareSecureArea.create(storage) var Zab = secureArea.keyAgreement( "CloudKey", state.otherPublicKey!!.ecPublicKey, @@ -793,7 +795,7 @@ class CloudSecureAreaServer( return Crypto.encrypt(Algorithm.A128GCM, e2eeState.skCloud!!, iv.array(), messagePlaintext) } - private fun doE2EERequest( + private suspend fun doE2EERequest( request: CloudSecureAreaProtocol.E2EERequest, remoteHost: String ): Pair { @@ -827,7 +829,7 @@ class CloudSecureAreaServer( return handleCommandInternal(plainText, remoteHost, e2eeState) } - private fun handleCommandInternal( + private suspend fun handleCommandInternal( requestData: ByteArray, remoteHost: String, e2eeState: E2EEState? @@ -860,7 +862,7 @@ class CloudSecureAreaServer( } } - fun handleCommand(requestData: ByteArray, remoteHost: String): Pair { + suspend fun handleCommand(requestData: ByteArray, remoteHost: String): Pair { return handleCommandInternal(requestData, remoteHost, null) } diff --git a/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/FlowEnvironmentStorageExt.kt b/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/FlowEnvironmentStorageExt.kt new file mode 100644 index 000000000..f5fb86c9f --- /dev/null +++ b/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/FlowEnvironmentStorageExt.kt @@ -0,0 +1,9 @@ +package com.android.identity.flow.server + +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec + +suspend fun FlowEnvironment.getTable(tableSpec: StorageTableSpec): StorageTable { + return getInterface(Storage::class)!!.getTable(tableSpec) +} \ No newline at end of file diff --git a/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/Storage.kt b/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/Storage.kt deleted file mode 100644 index f7e63f2c2..000000000 --- a/identity-flow/src/commonMain/kotlin/com/android/identity/flow/server/Storage.kt +++ /dev/null @@ -1,56 +0,0 @@ -package com.android.identity.flow.server - -import kotlinx.io.bytestring.ByteString - -/** - * Simple persistent storage interface. - * - * Data records are organized using tables, keys, and peerIds. Table must be an ASCII string - * that does not come from an external source (as it is not escaped). It may or may not be - * case-sensitive. Key is a string that uniquely identifies a record in a table. Additionally, - * peerId is a string that identifies a "counterpart" entity, i.e. a particular client in the - * server environment, or a particular server in the client environment. In most cases all - * three record identifiers must be provided (table, peerId, and the key). - * - * Payload of the record is always just a blob of data. Storage does not interpret that data - * in any way. - */ -interface Storage { - /** - * Retrieves data from storage. - * - * Returns null if record is not found. - */ - suspend fun get(table: String, peerId: String, key: String): ByteString? - - /** - * Inserts a new record. - * - * If the [key] is empty, a new unique key is generated. New key is guaranteed to only use - * URL-safe characters. - */ - suspend fun insert(table: String, peerId: String, data: ByteString, key: String = ""): String - - /** - * Updates the data of an existing record. - */ - suspend fun update(table: String, peerId: String, key: String, data: ByteString) - - /** - * Deletes a record. - * - * Returns true if record was deleted, false if it was not found. - */ - suspend fun delete(table: String, peerId: String, key: String): Boolean - - /** - * Enumerate keys of the records with given table and peerId in key lexicographic order. - * - * If [limit] is given, no more than the given number of keys are returned. If [notBeforeKey] - * is given only keys that follow the give key are returned. By specifying the desired [limit] - * and passing last key from the previously returned list as [notBeforeKey] allows enumerating - * all of the key in manageable chunks. - */ - suspend fun enumerate(table: String, peerId: String, - notBeforeKey: String = "", limit: Int = Int.MAX_VALUE): List -} \ No newline at end of file diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt b/identity-flow/src/jvmMain/kotlin/com/android/identity/flow/EnvironmentCacheExt.kt similarity index 97% rename from identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt rename to identity-flow/src/jvmMain/kotlin/com/android/identity/flow/EnvironmentCacheExt.kt index 670cacdd3..a4c26f8e3 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/common/EnvironmentCacheExt.kt +++ b/identity-flow/src/jvmMain/kotlin/com/android/identity/flow/EnvironmentCacheExt.kt @@ -1,4 +1,4 @@ -package com.android.identity.issuance.common +package com.android.identity.flow import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt b/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt index 306aa4c1e..acd2e6799 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/authenticationUtilities.kt @@ -8,7 +8,7 @@ import com.android.identity.device.DeviceAttestation import com.android.identity.device.DeviceAttestationIos import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache import com.android.identity.securearea.KeyAttestation import com.android.identity.util.isCloudKeyAttestation import com.android.identity.util.validateAndroidKeyAttestation diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt index a883f305d..7cc843580 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeIssuingAuthorityState.kt @@ -2,12 +2,8 @@ package com.android.identity.issuance.funke import com.android.identity.cbor.Cbor import com.android.identity.cbor.annotation.CborSerializable -import com.android.identity.crypto.Algorithm -import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve import com.android.identity.crypto.EcPublicKey -import com.android.identity.device.AssertionDPoPKey -import com.android.identity.device.DeviceAssertionMaker import com.android.identity.document.NameSpacedData import com.android.identity.documenttype.DocumentType import com.android.identity.documenttype.DocumentTypeRepository @@ -22,7 +18,6 @@ import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage import com.android.identity.issuance.ApplicationSupport import com.android.identity.issuance.CredentialConfiguration import com.android.identity.issuance.CredentialData @@ -41,17 +36,19 @@ import com.android.identity.securearea.config.SecureAreaConfigurationAndroidKeys import com.android.identity.securearea.config.SecureAreaConfigurationCloud import com.android.identity.issuance.WalletApplicationCapabilities import com.android.identity.issuance.common.AbstractIssuingAuthorityState -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.issuance.fromCbor import com.android.identity.issuance.wallet.ApplicationSupportState +import com.android.identity.issuance.wallet.AuthenticationState import com.android.identity.mdoc.mso.MobileSecurityObjectParser import com.android.identity.mdoc.mso.StaticAuthDataParser import com.android.identity.sdjwt.SdJwtVerifiableCredential import com.android.identity.sdjwt.vc.JwtBody import com.android.identity.securearea.KeyPurpose +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url -import com.android.identity.util.toBase64Url import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.request.headers @@ -69,7 +66,6 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlin.random.Random import kotlin.time.Duration.Companion.seconds @FlowState( @@ -94,7 +90,11 @@ class FunkeIssuingAuthorityState( companion object { private const val TAG = "FunkeIssuingAuthorityState" - private const val DOCUMENT_TABLE = "FunkeIssuerDocument" + val documentTableSpec = StorageTableSpec( + name = "Openid4VciIssuerDocument", + supportExpiration = false, + supportPartitions = true + ) suspend fun getConfiguration( env: FlowEnvironment, @@ -256,13 +256,8 @@ class FunkeIssuingAuthorityState( @FlowMethod suspend fun proof(env: FlowEnvironment, documentId: String): FunkeProofingState { - val metadata = Openid4VciIssuerMetadata.get(env, credentialIssuerUri) - val storage = env.getInterface(Storage::class)!! - val applicationCapabilities = storage.get( - "WalletApplicationCapabilities", - "", - clientId - )?.let { + val storage = env.getTable(AuthenticationState.walletAppCapabilitiesTableSpec) + val applicationCapabilities = storage.get(clientId)?.let { WalletApplicationCapabilities.fromCbor(it.toByteArray()) } ?: throw IllegalStateException("WalletApplicationCapabilities not found") return FunkeProofingState( @@ -605,8 +600,8 @@ class FunkeIssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - val encodedCbor = storage.get(DOCUMENT_TABLE, clientId, documentId) + val storage = env.getTable(documentTableSpec) + val encodedCbor = storage.get(partitionId = clientId, key = documentId) return encodedCbor != null } @@ -614,8 +609,8 @@ class FunkeIssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - val encodedCbor = storage.get(DOCUMENT_TABLE, clientId, documentId) + val storage = env.getTable(documentTableSpec) + val encodedCbor = storage.get(partitionId = clientId, key = documentId) ?: throw Error("No such document") return FunkeIssuerDocument.fromCbor(encodedCbor.toByteArray()) } @@ -624,9 +619,9 @@ class FunkeIssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! + val storage = env.getTable(documentTableSpec) val bytes = document.toCbor() - return storage.insert(DOCUMENT_TABLE, clientId, ByteString(bytes)) + return storage.insert(key = null, partitionId = clientId, data = ByteString(bytes)) } private suspend fun deleteIssuerDocument(env: FlowEnvironment, @@ -635,8 +630,8 @@ class FunkeIssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - storage.delete(DOCUMENT_TABLE, clientId, documentId) + val storage = env.getTable(documentTableSpec) + storage.delete(partitionId = clientId, key = documentId) if (emitNotification) { emit(env, IssuingAuthorityNotification(documentId)) } @@ -651,9 +646,9 @@ class FunkeIssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! + val storage = env.getTable(documentTableSpec) val bytes = document.toCbor() - storage.update(DOCUMENT_TABLE, clientId, documentId, ByteString(bytes)) + storage.update(partitionId = clientId, key = documentId, data = ByteString(bytes)) if (emitNotification) { Logger.i(TAG, "Emitting notification for $documentId") emit(env, IssuingAuthorityNotification(documentId)) diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt index 77ae7bdc3..37ec3981a 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/FunkeUtil.kt @@ -7,6 +7,7 @@ import com.android.identity.issuance.IssuingAuthorityException import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.util.Logger import com.android.identity.util.toBase64Url import io.ktor.client.HttpClient @@ -28,7 +29,7 @@ internal object FunkeUtil { private val keyCreationMutex = Mutex() suspend fun communicationKey(env: FlowEnvironment, clientId: String): KeyInfo { - val secureArea = env.getInterface(SecureArea::class)!! + val secureArea = env.getInterface(SecureAreaProvider::class)!!.get() val alias = "FunkeComm_" + clientId return try { secureArea.getKeyInfo(alias) @@ -44,8 +45,8 @@ internal object FunkeUtil { } } - fun communicationSign(env: FlowEnvironment, clientId: String, message: ByteArray): ByteArray { - val secureArea = env.getInterface(SecureArea::class)!! + suspend fun communicationSign(env: FlowEnvironment, clientId: String, message: ByteArray): ByteArray { + val secureArea = env.getInterface(SecureAreaProvider::class)!!.get() val alias = "FunkeComm_" + clientId val sig = secureArea.sign(alias, Algorithm.ES256, message, null) return sig.toCoseEncoded() diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt index 6dc83afd7..b73dc3fe2 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/RequestCredentialsUsingProofOfPossession.kt @@ -5,7 +5,7 @@ import com.android.identity.device.DeviceAssertion import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.issuance.CredentialConfiguration import com.android.identity.issuance.CredentialFormat import com.android.identity.issuance.CredentialRequest @@ -13,6 +13,7 @@ import com.android.identity.issuance.KeyPossessionChallenge import com.android.identity.issuance.KeyPossessionProof import com.android.identity.issuance.RequestCredentialsFlow import com.android.identity.issuance.validateDeviceAssertionBindingKeys +import com.android.identity.issuance.wallet.AuthenticationState import com.android.identity.issuance.wallet.ClientRecord import com.android.identity.issuance.wallet.fromCbor import com.android.identity.util.toBase64Url @@ -54,9 +55,8 @@ class RequestCredentialsUsingProofOfPossession( if (credentialRequests != null) { throw IllegalStateException("Credentials were already sent") } - val storage = env.getInterface(Storage::class)!! - val clientRecord = ClientRecord.fromCbor( - storage.get("Clients", "", clientId)!!.toByteArray()) + val storage = env.getTable(AuthenticationState.clientTableSpec) + val clientRecord = ClientRecord.fromCbor(storage.get(clientId)!!.toByteArray()) validateDeviceAssertionBindingKeys( env = env, deviceAttestation = clientRecord.deviceAttestation, diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt index b184e8a96..746ec1b66 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/funke/openid4VciIssuerMetadata.kt @@ -1,7 +1,7 @@ package com.android.identity.issuance.funke import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache import com.android.identity.util.Logger import io.ktor.client.HttpClient import io.ktor.client.request.get @@ -13,8 +13,6 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt index c0ec8c134..71f29e69e 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/IssuingAuthorityState.kt @@ -33,7 +33,6 @@ import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage import com.android.identity.flow.server.FlowEnvironment import com.android.identity.issuance.CredentialData import com.android.identity.issuance.CredentialFormat @@ -49,7 +48,8 @@ import com.android.identity.issuance.SdJwtVcDocumentConfiguration import com.android.identity.issuance.WalletApplicationCapabilities import com.android.identity.issuance.WalletServerSettings import com.android.identity.issuance.common.AbstractIssuingAuthorityState -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseGermanEidResolved import com.android.identity.issuance.evidence.EvidenceResponseIcaoNfcTunnelResult @@ -57,6 +57,7 @@ import com.android.identity.issuance.evidence.EvidenceResponseIcaoPassiveAuthent import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice import com.android.identity.issuance.fromCbor import com.android.identity.issuance.proofing.defaultCredentialConfiguration +import com.android.identity.issuance.wallet.AuthenticationState import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.util.MdocUtil @@ -65,6 +66,7 @@ import com.android.identity.mrtd.MrtdNfcDataDecoder import com.android.identity.sdjwt.Issuer import com.android.identity.sdjwt.SdJwtVcGenerator import com.android.identity.sdjwt.util.JsonWebKey +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock @@ -163,6 +165,12 @@ class IssuingAuthorityState( addDocumentType(EUPersonalID.getDocumentType()) addDocumentType(PhotoID.getDocumentType()) } + + val documentTableSpec = StorageTableSpec( + name = "HardcodedIssuerDocument", + supportPartitions = true, + supportExpiration = false + ) } @FlowMethod @@ -273,16 +281,10 @@ class IssuingAuthorityState( val issuerDocument = loadIssuerDocument(env, documentId) check(issuerDocument.state == DocumentCondition.READY) - val storage = env.getInterface(Storage::class)!! - val walletApplicationCapabilities = runBlocking { - storage.get( - "WalletApplicationCapabilities", - "", - clientId - )?.let { + val storage = env.getTable(AuthenticationState.walletAppCapabilitiesTableSpec) + val walletApplicationCapabilities = storage.get(clientId)?.let { WalletApplicationCapabilities.fromCbor(it.toByteArray()) } ?: throw IllegalStateException("WalletApplicationCapabilities not found") - } val credentialConfiguration = defaultCredentialConfiguration( documentId, @@ -1153,24 +1155,19 @@ class IssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - val encodedCbor = storage.get("IssuerDocument", clientId, documentId) - if (encodedCbor != null) { - return true - } - return false + val storage = env.getTable(documentTableSpec) + val encodedCbor = storage.get(partitionId = clientId, key = documentId) + return encodedCbor != null } private suspend fun loadIssuerDocument(env: FlowEnvironment, documentId: String): IssuerDocument { if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - val encodedCbor = storage.get("IssuerDocument", clientId, documentId) + val storage = env.getTable(documentTableSpec) + val encodedCbor = storage.get(partitionId = clientId, key = documentId) if (encodedCbor == null) { - // TODO: We need to figure out if we need to support throwing exceptions across - // the network. For example we would throw UnknownDocumentException here if this - // was supported by the Flow library/processor. + // TODO: replace with (new) UnknownDocumentException throw Error("No such document") } return IssuerDocument.fromDataItem(Cbor.decode(encodedCbor.toByteArray())) @@ -1180,9 +1177,9 @@ class IssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! + val storage = env.getTable(documentTableSpec) val bytes = Cbor.encode(document.toDataItem()) - return storage.insert("IssuerDocument", clientId, ByteString(bytes)) + return storage.insert(partitionId = clientId, key = null, data = ByteString(bytes)) } private suspend fun deleteIssuerDocument(env: FlowEnvironment, @@ -1191,8 +1188,8 @@ class IssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! - storage.delete("IssuerDocument", clientId, documentId) + val storage = env.getTable(documentTableSpec) + storage.delete(partitionId = clientId, key = documentId) if (emitNotification) { emit(env, IssuingAuthorityNotification(documentId)) } @@ -1207,9 +1204,9 @@ class IssuingAuthorityState( if (clientId.isEmpty()) { throw IllegalStateException("Client not authenticated") } - val storage = env.getInterface(Storage::class)!! + val storage = env.getTable(documentTableSpec) val bytes = Cbor.encode(document.toDataItem()) - storage.update("IssuerDocument", clientId, documentId, ByteString(bytes)) + storage.update(partitionId = clientId, key = documentId, data = ByteString(bytes)) if (emitNotification) { emit(env, IssuingAuthorityNotification(documentId)) } diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt index 33a6fa084..50b128922 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/hardcoded/ProofingState.kt @@ -4,11 +4,11 @@ import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage import com.android.identity.issuance.ProofingFlow import com.android.identity.issuance.WalletApplicationCapabilities import com.android.identity.issuance.WalletServerSettings -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.issuance.evidence.EvidenceResponse import com.android.identity.issuance.evidence.EvidenceResponseGermanEid @@ -20,11 +20,11 @@ import com.android.identity.issuance.fromCbor import com.android.identity.issuance.proofing.ProofingGraph import com.android.identity.issuance.proofing.defaultGraph import com.android.identity.issuance.tunnel.inProcessMrtdNfcTunnelFactory +import com.android.identity.issuance.wallet.AuthenticationState import com.android.identity.mrtd.MrtdAccessDataCan import io.ktor.client.HttpClient import io.ktor.client.request.get import io.ktor.client.statement.readBytes -import kotlinx.coroutines.runBlocking /** * State of [ProofingFlow] RPC implementation. @@ -127,16 +127,10 @@ class ProofingState( } private suspend fun getGraph(env: FlowEnvironment): ProofingGraph { - val storage = env.getInterface(Storage::class)!! - val walletApplicationCapabilities = runBlocking { - storage.get( - "WalletApplicationCapabilities", - "", - clientId - )?.let { - WalletApplicationCapabilities.fromCbor(it.toByteArray()) - } ?: throw IllegalStateException("WalletApplicationCapabilities not found") - } + val storage = env.getTable(AuthenticationState.walletAppCapabilitiesTableSpec) + val walletApplicationCapabilities = storage.get(clientId)?.let { + WalletApplicationCapabilities.fromCbor(it.toByteArray()) + } ?: throw IllegalStateException("WalletApplicationCapabilities not found") val key = GraphKey(issuingAuthorityId, documentId, developerModeEnabled) return env.cache(ProofingGraph::class, key) { configuration, resources -> diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt index bd32494f8..1af0705d7 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/ApplicationSupportState.kt @@ -12,14 +12,15 @@ import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage import com.android.identity.issuance.ApplicationSupport import com.android.identity.issuance.LandingUrlUnknownException import com.android.identity.issuance.WalletServerSettings -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.issuance.funke.toJson import com.android.identity.issuance.validateDeviceAssertionBindingKeys import com.android.identity.securearea.KeyAttestation +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity.util.toBase64Url import com.android.identity.util.validateAndroidKeyAttestation @@ -30,6 +31,7 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -47,12 +49,24 @@ class ApplicationSupportState( // clientId field above! It identifies our wallet app to OpenId4VCI servers, whereas // clientId identifies a particular wallet app instance to the wallet server. const val FUNKE_CLIENT_ID = "60f8c117-b692-4de8-8f7f-636ff852baa6" + + val landingTableSpec = StorageTableSpec( + name = "LandingUrls", + supportPartitions = false, + supportExpiration = true + ) + + private val EXPIRATION = 1.days } @FlowMethod suspend fun createLandingUrl(env: FlowEnvironment): String { - val storage = env.getInterface(Storage::class)!! - val id = storage.insert("Landing", "", ByteString(LandingRecord(clientId).toCbor())) + val storage = env.getTable(landingTableSpec) + val id = storage.insert( + key = null, + data = ByteString(LandingRecord(clientId).toCbor()), + expiration = Clock.System.now() + EXPIRATION + ) Logger.i(TAG, "Created landing URL '$id'") val configuration = env.getInterface(Configuration::class)!! val baseUrl = configuration.getValue("base_url")!! @@ -68,15 +82,15 @@ class ApplicationSupportState( Logger.e(TAG, "baseUrl must start with $prefix, actual '$landingUrl'") throw IllegalStateException("baseUrl must start with $prefix") } - val storage = env.getInterface(Storage::class)!! + val storage = env.getTable(landingTableSpec) val id = landingUrl.substring(prefix.length) Logger.i(TAG, "Querying landing URL '$id'") - val recordData = storage.get("Landing", "", id) + val recordData = storage.get(id) ?: throw LandingUrlUnknownException("No landing url '$id'") val record = LandingRecord.fromCbor(recordData.toByteArray()) if (record.resolved != null) { Logger.i(TAG, "Removed landing URL '$id'") - storage.delete("Landing", "", id) + storage.delete(id) } return record.resolved } @@ -87,9 +101,8 @@ class ApplicationSupportState( keyAttestation: KeyAttestation, keyAssertion: DeviceAssertion ): String { - val storage = env.getInterface(Storage::class)!! - val clientRecord = ClientRecord.fromCbor( - storage.get("Clients", "", clientId)!!.toByteArray()) + val storage = env.getTable(AuthenticationState.clientTableSpec) + val clientRecord = ClientRecord.fromCbor(storage.get(clientId)!!.toByteArray()) clientRecord.deviceAttestation.validateAssertion(keyAssertion) @@ -123,9 +136,8 @@ class ApplicationSupportState( keyAttestations: List, keysAssertion: DeviceAssertion // holds AssertionBindingKeys ): String { - val storage = env.getInterface(Storage::class)!! - val clientRecord = ClientRecord.fromCbor( - storage.get("Clients", "", clientId)!!.toByteArray()) + val storage = env.getTable(AuthenticationState.clientTableSpec) + val clientRecord = ClientRecord.fromCbor(storage.get(clientId)!!.toByteArray()) val assertion = validateDeviceAssertionBindingKeys( env = env, deviceAttestation = clientRecord.deviceAttestation, diff --git a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt index 7a9928e22..808524e53 100644 --- a/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt +++ b/identity-issuance/src/main/java/com/android/identity/issuance/wallet/AuthenticationState.kt @@ -7,14 +7,15 @@ import com.android.identity.device.DeviceAttestationValidationData import com.android.identity.flow.annotation.FlowMethod import com.android.identity.flow.annotation.FlowState import com.android.identity.flow.server.Configuration -import com.android.identity.flow.server.Storage import com.android.identity.flow.server.FlowEnvironment +import com.android.identity.flow.server.getTable import com.android.identity.issuance.AuthenticationFlow import com.android.identity.issuance.ClientAuthentication import com.android.identity.issuance.ClientChallenge import com.android.identity.issuance.WalletServerCapabilities import com.android.identity.issuance.WalletServerSettings import com.android.identity.issuance.toCbor +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.toBase64Url import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString @@ -28,14 +29,26 @@ class AuthenticationState( var deviceAttestation: DeviceAttestation? = null, var authenticated: Boolean = false ) { - companion object + companion object { + val clientTableSpec = StorageTableSpec( + name = "Clients", + supportPartitions = false, + supportExpiration = false + ) + + val walletAppCapabilitiesTableSpec = StorageTableSpec( + name = "WalletAppCapabilities", + supportPartitions = false, + supportExpiration = false + ) + } @FlowMethod suspend fun requestChallenge(env: FlowEnvironment, clientId: String): ClientChallenge { check(this.clientId.isEmpty()) check(nonce != null) - val storage = env.getInterface(Storage::class)!! - val clientData = storage.get("Clients", "", clientId) + val clientTable = env.getTable(clientTableSpec) + val clientData = clientTable.get(clientId) if (clientData != null) { this.deviceAttestation = ClientRecord.fromCbor(clientData.toByteArray()).deviceAttestation @@ -54,7 +67,7 @@ class AuthenticationState( @FlowMethod suspend fun authenticate(env: FlowEnvironment, auth: ClientAuthentication): WalletServerCapabilities { val settings = WalletServerSettings(env.getInterface(Configuration::class)!!) - val storage = env.getInterface(Storage::class)!! + val clientTable = env.getTable(clientTableSpec) val attestation = auth.attestation if (attestation != null) { @@ -71,7 +84,7 @@ class AuthenticationState( )) val clientData = ByteString(ClientRecord(attestation).toCbor()) this.deviceAttestation = attestation - storage.insert("Clients", "", clientData, key = clientId) + clientTable.insert(data = clientData, key = clientId) } this.deviceAttestation!!.validateAssertion(auth.assertion) @@ -80,23 +93,16 @@ class AuthenticationState( throw IllegalArgumentException("nonce mismatch") } authenticated = true - if (storage.get( - "WalletApplicationCapabilities", - "", - clientId, - ) == null) { - storage.insert( - "WalletApplicationCapabilities", - "", - ByteString(auth.walletApplicationCapabilities.toCbor()), - clientId + val walletAppCapabilitiesTable = env.getTable(walletAppCapabilitiesTableSpec) + if (walletAppCapabilitiesTable.get(clientId) == null) { + walletAppCapabilitiesTable.insert( + key = clientId, + data = ByteString(auth.walletApplicationCapabilities.toCbor()), ) } else { - storage.update( - "WalletApplicationCapabilities", - "", - clientId, - ByteString(auth.walletApplicationCapabilities.toCbor()) + walletAppCapabilitiesTable.update( + key = clientId, + data = ByteString(auth.walletApplicationCapabilities.toCbor()) ) } return WalletServerCapabilities( diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/credential/MdocCredential.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/credential/MdocCredential.kt index d104827e3..4bff2ae42 100644 --- a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/credential/MdocCredential.kt +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/credential/MdocCredential.kt @@ -40,38 +40,41 @@ class MdocCredential : SecureAreaBoundCredential { /** * Constructs a new [MdocCredential]. * + * [SecureAreaBoundCredential.generateKey] providing [CreateKeySettings] must be called before using + * this object. + * * @param document the document to add the credential to. * @param asReplacementFor the credential this credential will replace, if not null * @param domain the domain of the credential * @param secureArea the secure area for the authentication key associated with this credential. - * @param createKeySettings the settings used to create new credentials. * @param docType the docType of the credential + * + * [SecureAreaBoundCredential.generateKey] must be called before using this object. */ constructor( document: Document, asReplacementFor: Credential?, domain: String, secureArea: SecureArea, - createKeySettings: CreateKeySettings, docType: String - ) : super(document, asReplacementFor, domain, secureArea, createKeySettings) { + ) : super(document, asReplacementFor, domain, secureArea) { this.docType = docType - // Only the leaf constructor should add the credential to the document. - if (this::class == MdocCredential::class) { - addToDocument() - } } /** * Constructs a Credential from serialized data. * + * [generateKey] providing actual serialized data must be called before using this object. + * * @param document the [Document] that the credential belongs to. * @param dataItem the serialized data. */ constructor( - document: Document, - dataItem: DataItem, - ) : super(document, dataItem) { + document: Document + ) : super(document) {} + + override suspend fun deserialize(dataItem: DataItem) { + super.deserialize(dataItem) docType = dataItem["docType"].asTstr } @@ -79,7 +82,8 @@ class MdocCredential : SecureAreaBoundCredential { * The docType of the credential as defined in * [ISO/IEC 18013-5:2021](https://www.iso.org/standard/69084.html). */ - val docType: String + lateinit var docType: String + private set override fun addSerializedData(builder: MapBuilder) { super.addSerializedData(builder) diff --git a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/response/DocumentGenerator.kt b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/response/DocumentGenerator.kt index 99d694858..eb1d54a07 100644 --- a/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/response/DocumentGenerator.kt +++ b/identity-mdoc/src/commonMain/kotlin/com/android/identity/mdoc/response/DocumentGenerator.kt @@ -78,8 +78,7 @@ class DocumentGenerator issuerNamespaces = issuerNameSpaces } - @Throws(KeyLockedException::class) - private fun setDeviceNamespaces( + private suspend fun setDeviceNamespaces( dataElements: NameSpacedData, secureArea: SecureArea, keyAlias: String, @@ -187,8 +186,7 @@ class DocumentGenerator * @return the generator. * @throws KeyLockedException if the authentication key is locked. */ - @Throws(KeyLockedException::class) - fun setDeviceNamespacesSignature( + suspend fun setDeviceNamespacesSignature( dataElements: NameSpacedData, secureArea: SecureArea, keyAlias: String, @@ -219,8 +217,7 @@ class DocumentGenerator * @return the generator. * @throws KeyLockedException if the authentication key is locked. */ - @Throws(KeyLockedException::class) - fun setDeviceNamespacesMac( + suspend fun setDeviceNamespacesMac( dataElements: NameSpacedData, secureArea: SecureArea, keyAlias: String, diff --git a/identity-mdoc/src/commonTest/kotlin/com/android/identity/mdoc/response/DeviceResponseGeneratorTest.kt b/identity-mdoc/src/commonTest/kotlin/com/android/identity/mdoc/response/DeviceResponseGeneratorTest.kt index 965addba6..b8f68f5a9 100644 --- a/identity-mdoc/src/commonTest/kotlin/com/android/identity/mdoc/response/DeviceResponseGeneratorTest.kt +++ b/identity-mdoc/src/commonTest/kotlin/com/android/identity/mdoc/response/DeviceResponseGeneratorTest.kt @@ -44,12 +44,12 @@ import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.mso.StaticAuthDataParser import com.android.identity.mdoc.util.MdocUtil import com.android.identity.securearea.KeyPurpose -import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlin.random.Random @@ -61,8 +61,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class DeviceResponseGeneratorTest { - private lateinit var storageEngine: StorageEngine - private lateinit var secureArea: SecureArea + private lateinit var storage: Storage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var credentialFactory: CredentialFactory @@ -76,24 +75,23 @@ class DeviceResponseGeneratorTest { @BeforeTest fun setup() { - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) + storage = EphemeralStorage() + secureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(MdocCredential::class) { - document, dataItem -> MdocCredential(document, dataItem) + document, dataItem -> MdocCredential(document).apply { deserialize(dataItem) } } - provisionDocument() } // This isn't really used, we only use a single domain. private val AUTH_KEY_DOMAIN = "domain" private val MDOC_CREDENTIAL_IDENTIFIER = "MdocCredential" - private fun provisionDocument() { + private suspend fun provisionDocument() { val documentStore = DocumentStore( - storageEngine, + storage, secureAreaRepository, credentialFactory ) @@ -123,24 +121,27 @@ class DeviceResponseGeneratorTest { val nowSeconds = Clock.System.now().epochSeconds timeSigned = Instant.fromEpochSeconds(nowSeconds) timeValidityBegin = Instant.fromEpochSeconds(nowSeconds + 3600) - timeValidityEnd = Instant.fromEpochSeconds(nowSeconds + 10*86400) + timeValidityEnd = Instant.fromEpochSeconds(nowSeconds + 10 * 86400) mdocCredential = MdocCredential( document, null, AUTH_KEY_DOMAIN, - secureArea, - SoftwareCreateKeySettings.Builder() - .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) - .build(), + secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!!, "org.iso.18013.5.1.mDL" - ) + ).apply { + generateKey( + SoftwareCreateKeySettings.Builder() + .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) + .build(), + ) + } assertFalse(mdocCredential.isCertified) // Generate an MSO and issuer-signed data for this authentication key. val msoGenerator = MobileSecurityObjectGenerator( "SHA-256", DOC_TYPE, - mdocCredential.attestation.publicKey + mdocCredential.getAttestation().publicKey ) msoGenerator.setValidityInfo(timeSigned, timeValidityBegin, timeValidityEnd, null) val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( @@ -215,7 +216,8 @@ class DeviceResponseGeneratorTest { } @Test - fun testDocumentGeneratorEcdsa() { + fun testDocumentGeneratorEcdsa() = runTest { + provisionDocument() // OK, now do the request... request a strict subset of the data in the document // and also request data not in the document. @@ -229,13 +231,15 @@ class DeviceResponseGeneratorTest { ) val request = DocumentRequest(dataElements) val encodedSessionTranscript = Cbor.encode(Tstr("Doesn't matter")) + val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData) .parse() - val mergedIssuerNamespaces: Map> = MdocUtil.mergeIssuerNamesSpaces( - request, - document.applicationData.getNameSpacedData("documentData"), - staticAuthData - ) + val mergedIssuerNamespaces: Map> = + MdocUtil.mergeIssuerNamesSpaces( + request, + document.applicationData.getNameSpacedData("documentData"), + staticAuthData + ) val deviceResponseGenerator = DeviceResponseGenerator(0) deviceResponseGenerator.addDocument( DocumentGenerator(DOC_TYPE, staticAuthData.issuerAuth, encodedSessionTranscript) @@ -272,7 +276,7 @@ class DeviceResponseGeneratorTest { // Check DeviceSigned data assertEquals(0, doc.deviceNamespaces.size.toLong()) // Check the key which signed DeviceSigned was the expected one. - assertEquals(mdocCredential.attestation.publicKey, doc.deviceKey) + assertEquals(mdocCredential.getAttestation().publicKey, doc.deviceKey) // Check DeviceSigned was correctly authenticated. assertTrue(doc.deviceSignedAuthenticated) assertTrue(doc.deviceSignedAuthenticatedViaSignature) @@ -295,7 +299,9 @@ class DeviceResponseGeneratorTest { } @Test - fun testDocumentGeneratorMac() { + fun testDocumentGeneratorMac() = runTest { + provisionDocument() + // Also check that Mac authentication works. This requires creating an ephemeral // reader key... we generate a new response, parse it, and check that the // DeviceSigned part is as expected. @@ -311,12 +317,12 @@ class DeviceResponseGeneratorTest { val encodedSessionTranscript = Cbor.encode(Tstr("Doesn't matter")) val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData) .parse() + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) val mergedIssuerNamespaces: Map> = MdocUtil.mergeIssuerNamesSpaces( request, document.applicationData.getNameSpacedData("documentData"), staticAuthData ) - val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) val deviceResponseGenerator = DeviceResponseGenerator(0) deviceResponseGenerator.addDocument( DocumentGenerator(DOC_TYPE, staticAuthData.issuerAuth, encodedSessionTranscript) @@ -331,6 +337,7 @@ class DeviceResponseGeneratorTest { .generate() ) val encodedDeviceResponse = deviceResponseGenerator.generate() + val parser = DeviceResponseParser( encodedDeviceResponse, encodedSessionTranscript @@ -344,7 +351,9 @@ class DeviceResponseGeneratorTest { } @Test - fun testDeviceSigned() { + fun testDeviceSigned() = runTest { + provisionDocument() + val dataElements = listOf( DataElement("ns1", "foo1", false, false), DataElement("ns1", "foo2", false, false), @@ -353,6 +362,7 @@ class DeviceResponseGeneratorTest { DataElement("ns2", "does_not_exist", false, false), DataElement("ns_does_not_exist", "boo", false, false) ) + val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) val request = DocumentRequest(dataElements) val encodedSessionTranscript = Cbor.encode(Tstr("Doesn't matter")) val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData) @@ -370,7 +380,6 @@ class DeviceResponseGeneratorTest { .putEntryString("ns4", "baz2", "bah2") .putEntryString("ns4", "baz3", "bah3") .build() - val eReaderKey = Crypto.createEcPrivateKey(EcCurve.P256) val deviceResponseGenerator = DeviceResponseGenerator(0) deviceResponseGenerator.addDocument( DocumentGenerator(DOC_TYPE, staticAuthData.issuerAuth, encodedSessionTranscript) @@ -385,6 +394,7 @@ class DeviceResponseGeneratorTest { .generate() ) val encodedDeviceResponse = deviceResponseGenerator.generate() + val parser = DeviceResponseParser( encodedDeviceResponse, encodedSessionTranscript @@ -420,7 +430,9 @@ class DeviceResponseGeneratorTest { } @Test - fun testDeviceSignedOnly() { + fun testDeviceSignedOnly() = runTest { + provisionDocument() + val encodedSessionTranscript = Cbor.encode(Tstr("Doesn't matter")) val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData) .parse() @@ -473,7 +485,9 @@ class DeviceResponseGeneratorTest { } @Test - fun testDocumentGeneratorDoNotSend() { + fun testDocumentGeneratorDoNotSend() = runTest { + provisionDocument() + val ns1_foo2 = DataElement("ns1", "foo2", false, false) ns1_foo2.doNotSend = true val dataElements = listOf( @@ -486,13 +500,15 @@ class DeviceResponseGeneratorTest { ) val request = DocumentRequest(dataElements) val encodedSessionTranscript = Cbor.encode(Tstr("Doesn't matter")) + val staticAuthData = StaticAuthDataParser(mdocCredential.issuerProvidedData) .parse() - val mergedIssuerNamespaces: Map> = MdocUtil.mergeIssuerNamesSpaces( - request, - document.applicationData.getNameSpacedData("documentData"), - staticAuthData - ) + val mergedIssuerNamespaces: Map> = + MdocUtil.mergeIssuerNamesSpaces( + request, + document.applicationData.getNameSpacedData("documentData"), + staticAuthData + ) val deviceResponseGenerator = DeviceResponseGenerator(0) deviceResponseGenerator.addDocument( DocumentGenerator(DOC_TYPE, staticAuthData.issuerAuth, encodedSessionTranscript) @@ -529,7 +545,7 @@ class DeviceResponseGeneratorTest { // Check DeviceSigned data assertEquals(0, doc.deviceNamespaces.size.toLong()) // Check the key which signed DeviceSigned was the expected one. - assertEquals(mdocCredential.attestation.publicKey, doc.deviceKey) + assertEquals(mdocCredential.getAttestation().publicKey, doc.deviceKey) // Check DeviceSigned was correctly authenticated. assertTrue(doc.deviceSignedAuthenticated) assertTrue(doc.deviceSignedAuthenticatedViaSignature) diff --git a/identity-sdjwt/build.gradle.kts b/identity-sdjwt/build.gradle.kts index d55760bce..79a44e0e7 100644 --- a/identity-sdjwt/build.gradle.kts +++ b/identity-sdjwt/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { testImplementation(libs.kotlin.test) testImplementation(libs.bouncy.castle.bcprov) testImplementation(libs.bouncy.castle.bcpkix) + testImplementation(libs.kotlinx.coroutine.test) } diff --git a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/SdJwtVerifiableCredential.kt b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/SdJwtVerifiableCredential.kt index 9f120673a..c636c5458 100644 --- a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/SdJwtVerifiableCredential.kt +++ b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/SdJwtVerifiableCredential.kt @@ -130,7 +130,7 @@ class SdJwtVerifiableCredential( } } - fun createPresentation(secureArea: SecureArea?, + suspend fun createPresentation(secureArea: SecureArea?, alias: String?, keyUnlockData: KeyUnlockData?, alg: Algorithm, diff --git a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeyBoundSdJwtVcCredential.kt b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeyBoundSdJwtVcCredential.kt index 79a0f699f..261104bdc 100644 --- a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeyBoundSdJwtVcCredential.kt +++ b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeyBoundSdJwtVcCredential.kt @@ -24,16 +24,18 @@ class KeyBoundSdJwtVcCredential : SecureAreaBoundCredential, SdJwtVcCredential { * [draft-ietf-oauth-sd-jwt-vc-03] * (https://datatracker.ietf.org/doc/draft-ietf-oauth-sd-jwt-vc/) */ - override val vct: String + override lateinit var vct: String + private set /** * Constructs a new [KeyBoundSdJwtVcCredential]. * + * [generateKey] providing [CreateKeySettings] must be called before using this object. + * * @param document the document to add the credential to. * @param asReplacementFor the credential this credential will replace, if not null * @param domain the domain of the credential * @param secureArea the secure area for the authentication key associated with this credential. - * @param createKeySettings the settings used to create new credentials. * @param vct the Verifiable Credential Type. */ constructor( @@ -41,26 +43,25 @@ class KeyBoundSdJwtVcCredential : SecureAreaBoundCredential, SdJwtVcCredential { asReplacementFor: Credential?, domain: String, secureArea: SecureArea, - createKeySettings: CreateKeySettings, vct: String, - ) : super(document, asReplacementFor, domain, secureArea, createKeySettings) { + ) : super(document, asReplacementFor, domain, secureArea) { this.vct = vct - // Only the leaf constructor should add the credential to the document. - if (this::class == KeyBoundSdJwtVcCredential::class) { - addToDocument() - } } /** * Constructs a Credential from serialized data. * + * [deserialize] providing serialized data must be called before using this object. + * * @param document the [Document] that the credential belongs to. - * @param dataItem the serialized data. */ constructor( - document: Document, - dataItem: DataItem, - ) : super(document, dataItem) { + document: Document + ) : super(document) { + } + + override suspend fun deserialize(dataItem: DataItem) { + super.deserialize(dataItem) vct = dataItem["vct"].asTstr } diff --git a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeylessSdJwtVcCredential.kt b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeylessSdJwtVcCredential.kt index beb218d27..a9f0ce9a7 100644 --- a/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeylessSdJwtVcCredential.kt +++ b/identity-sdjwt/src/main/java/com/android/identity/sdjwt/credential/KeylessSdJwtVcCredential.kt @@ -7,7 +7,8 @@ import com.android.identity.credential.Credential import com.android.identity.document.Document class KeylessSdJwtVcCredential : Credential, SdJwtVcCredential { - override val vct: String + override lateinit var vct: String + private set /** * Constructs a new [KeyBoundSdJwtVcCredential]. @@ -24,22 +25,22 @@ class KeylessSdJwtVcCredential : Credential, SdJwtVcCredential { vct: String, ) : super(document, asReplacementFor, domain) { this.vct = vct - // Only the leaf constructor should add the credential to the document. - if (this::class == KeylessSdJwtVcCredential::class) { - addToDocument() - } + // Only the leaf constructors for keyless credentials should add the credential to + // the document. + check (this::class == KeylessSdJwtVcCredential::class) + addToDocument() } /** * Constructs a Credential from serialized data. * + * [deserialize] providing actual serialized data must be called before using this object. + * * @param document the [Document] that the credential belongs to. - * @param dataItem the serialized data. */ - constructor( - document: Document, - dataItem: DataItem, - ) : super(document, dataItem) { + constructor(document: Document) : super(document) + + override suspend fun deserialize(dataItem: DataItem) { vct = dataItem["vct"].asTstr } diff --git a/identity-sdjwt/src/test/java/com/android/identity/sdjwt/SdJwtVcTest.kt b/identity-sdjwt/src/test/java/com/android/identity/sdjwt/SdJwtVcTest.kt index 09bf25bb5..1528050f6 100644 --- a/identity-sdjwt/src/test/java/com/android/identity/sdjwt/SdJwtVcTest.kt +++ b/identity-sdjwt/src/test/java/com/android/identity/sdjwt/SdJwtVcTest.kt @@ -19,6 +19,8 @@ import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareSecureArea import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.serialization.json.boolean @@ -40,8 +42,7 @@ import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours class SdJwtVcTest { - - private lateinit var secureArea: SoftwareSecureArea + private lateinit var storage: EphemeralStorage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var credentialFactory: CredentialFactory private lateinit var storageEngine: EphemeralStorageEngine @@ -56,20 +57,19 @@ class SdJwtVcTest { @Before fun setup() { Security.insertProviderAt(BouncyCastleProvider(), 1) - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) + storage = EphemeralStorage() + secureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(KeyBoundSdJwtVcCredential::class) { - document, dataItem -> KeyBoundSdJwtVcCredential(document, dataItem) + document, dataItem -> KeyBoundSdJwtVcCredential(document).apply { deserialize(dataItem) } } - provisionCredential() } - private fun provisionCredential() { + private suspend fun provisionCredential() { val documentStore = DocumentStore( - storageEngine, + storage, secureAreaRepository, credentialFactory ) @@ -87,12 +87,14 @@ class SdJwtVcTest { document, null, "domain", - secureArea, - SoftwareCreateKeySettings.Builder() + secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!!, + "IdentityCredential", + ).apply { + generateKey(SoftwareCreateKeySettings.Builder() .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) .build(), - "IdentityCredential", - ) + ) + } // at the issuer, start creating the credential... val identityAttributes = buildJsonObject { @@ -102,9 +104,9 @@ class SdJwtVcTest { put("over_18", true) put("over_21", true) put("address", buildJsonObject { - put("street_address", "123 Main St") + put("street_address", "123 Main St") put("locality", "Anytown") - put("region", "Anystate") + put("region", "Anystate") put("country", "US") }) } @@ -134,7 +136,7 @@ class SdJwtVcTest { issuer = Issuer("https://example-issuer.com", Algorithm.ES256, "key-1") ) - sdJwtVcGenerator.publicKey = JsonWebKey(credential.attestation.publicKey) + sdJwtVcGenerator.publicKey = JsonWebKey(credential.getAttestation().publicKey) sdJwtVcGenerator.timeSigned = timeSigned sdJwtVcGenerator.timeValidityBegin = timeValidityBegin sdJwtVcGenerator.timeValidityEnd = timeValidityEnd @@ -159,12 +161,14 @@ class SdJwtVcTest { credential.certify( sdJwt.toString().toByteArray(), timeValidityBegin, - timeValidityEnd) + timeValidityEnd + ) } @OptIn(ExperimentalEncodingApi::class) @Test - fun testPresentationVerificationEcdsa() { + fun testPresentationVerificationEcdsa() = runTest { + provisionCredential() // on the holder device, let's prepare a presentation // we'll start by reading back the SD-JWT issued to us for the auth key diff --git a/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt index d27c74c3e..5c37c2003 100644 --- a/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt +++ b/identity/src/androidInstrumentedTest/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureAreaTest.kt @@ -33,8 +33,11 @@ import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose -import com.android.identity.storage.GenericStorageEngine +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.android.AndroidStorage import com.android.identity.util.AndroidAttestationExtensionParser +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock import org.bouncycastle.jce.provider.BouncyCastleProvider import org.junit.Assert import org.junit.Assume @@ -52,12 +55,10 @@ import java.security.Security import java.security.cert.Certificate import java.security.cert.CertificateException import kotlinx.datetime.Instant.Companion.fromEpochMilliseconds -import kotlinx.io.files.Path -import kotlinx.io.files.SystemFileSystem class AndroidKeystoreSecureAreaTest { - private lateinit var ks: AndroidKeystoreSecureArea + private lateinit var secureAreaProvider: SecureAreaProvider @Before fun setup() { @@ -68,16 +69,18 @@ class AndroidKeystoreSecureAreaTest { val context = InstrumentationRegistry.getTargetContext() - val storageFile = Path(context.dataDir.path, "testdata.bin") - SystemFileSystem.delete(storageFile, false) - val storageEngine = GenericStorageEngine(storageFile) - ks = AndroidKeystoreSecureArea(context, storageEngine) + val storage = AndroidStorage(databasePath = null, clock = Clock.System) + secureAreaProvider = SecureAreaProvider { + AndroidKeystoreSecureArea.create(context, storage) + } } @Test - fun testEcKeyDeletion() { + fun testEcKeyDeletion() = runTest { val settings = AndroidKeystoreCreateKeySettings.Builder(byteArrayOf(1, 2, 3)).build() + val ks = secureAreaProvider.get() + // First create the key... ks.createKey("testKey", settings) @@ -110,7 +113,8 @@ class AndroidKeystoreSecureAreaTest { testEcKeySigningHelper(true) } - fun testEcKeySigningHelper(useStrongBox: Boolean) { + fun testEcKeySigningHelper(useStrongBox: Boolean) = runTest { + val ks = secureAreaProvider.get() val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge) .setUseStrongBox(useStrongBox) @@ -156,8 +160,10 @@ class AndroidKeystoreSecureAreaTest { testEcKeySigningAuthBoundHelper(true) } - fun testEcKeySigningAuthBoundHelper(useStrongBox: Boolean) { + fun testEcKeySigningAuthBoundHelper(useStrongBox: Boolean) = runTest { Assume.assumeFalse(TestUtil.isRunningOnEmulator) + val ks = secureAreaProvider.get() + val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge) .setUseStrongBox(useStrongBox) @@ -194,11 +200,13 @@ class AndroidKeystoreSecureAreaTest { } @Test - fun testEcKeyAuthenticationTypeLskf() { + fun testEcKeyAuthenticationTypeLskf() = runTest { Assume.assumeFalse(TestUtil.isRunningOnEmulator) // setUserAuthenticationParameters() is only available on API 30 or later. // Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + + val ks = secureAreaProvider.get() val type = setOf(UserAuthenticationType.LSKF) val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge) @@ -225,11 +233,13 @@ class AndroidKeystoreSecureAreaTest { } @Test - fun testEcKeyAuthenticationTypeBiometric() { + fun testEcKeyAuthenticationTypeBiometric() = runTest { Assume.assumeFalse(TestUtil.isRunningOnEmulator) // setUserAuthenticationParameters() is only available on API 30 or later. // Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + + val ks = secureAreaProvider.get() val type = setOf(UserAuthenticationType.BIOMETRIC) val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge) @@ -275,12 +285,14 @@ class AndroidKeystoreSecureAreaTest { // Curve 25519 on Android is currently broken, see b/282063229 for details. Ignore test for now. @Ignore @Test - fun testEcKeySigningEd25519() { + fun testEcKeySigningEd25519() = runTest { // ECDH is only available on Android 12 or later (only HW-backed on Keymint 1.0 or later) // // Also note it's not available on StrongBox. // Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + val ks = secureAreaProvider.get() + val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge) .setEcCurve(EcCurve.ED25519) @@ -315,7 +327,7 @@ class AndroidKeystoreSecureAreaTest { @Test @Throws(IOException::class) - fun testEcKeySigningWithKeyWithoutCorrectPurpose() { + fun testEcKeySigningWithKeyWithoutCorrectPurpose() = runTest { // According to https://developer.android.com/reference/android/content/pm/PackageManager#FEATURE_HARDWARE_KEYSTORE // ECDH is available if FEATURE_HARDWARE_KEYSTORE is >= 100. val context = InstrumentationRegistry.getTargetContext() @@ -324,6 +336,8 @@ class AndroidKeystoreSecureAreaTest { PackageManager.FEATURE_HARDWARE_KEYSTORE, 100 ) ) + + val ks = secureAreaProvider.get() ks.createKey( "testKey", AndroidKeystoreCreateKeySettings.Builder(byteArrayOf(1, 2, 3)) @@ -367,8 +381,9 @@ class AndroidKeystoreSecureAreaTest { testEcdhHelper(true) } - fun testEcdhHelper(useStrongBox: Boolean) { + fun testEcdhHelper(useStrongBox: Boolean) = runTest { val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) + val ks = secureAreaProvider.get() ks.createKey( "testKey", AndroidKeystoreCreateKeySettings.Builder(byteArrayOf(1, 2, 3)) @@ -406,12 +421,15 @@ class AndroidKeystoreSecureAreaTest { // Curve 25519 on Android is currently broken, see b/282063229 for details. Ignore test for now. @Ignore @Test - fun testEcdhX25519() { + fun testEcdhX25519() = runTest { // ECDH is only available on Android 12 or later (only HW-backed on Keymint 1.0 or later) // // Also note it's not available on StrongBox. // Assume.assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + + val ks = secureAreaProvider.get() + val otherKey = Crypto.createEcPrivateKey(EcCurve.X25519) ks.createKey( "testKey", @@ -473,7 +491,8 @@ class AndroidKeystoreSecureAreaTest { testEcdhAndSigningHelper(true) } - fun testEcdhAndSigningHelper(useStrongBox: Boolean) { + fun testEcdhAndSigningHelper(useStrongBox: Boolean) = runTest { + val ks = secureAreaProvider.get() val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) ks.createKey( "testKey", @@ -525,7 +544,7 @@ class AndroidKeystoreSecureAreaTest { @Test @Throws(IOException::class) - fun testEcdhWithoutCorrectPurpose() { + fun testEcdhWithoutCorrectPurpose() = runTest { // According to https://developer.android.com/reference/android/content/pm/PackageManager#FEATURE_HARDWARE_KEYSTORE // ECDH is available if FEATURE_HARDWARE_KEYSTORE is >= 100. val context = InstrumentationRegistry.getTargetContext() @@ -534,6 +553,7 @@ class AndroidKeystoreSecureAreaTest { PackageManager.FEATURE_HARDWARE_KEYSTORE, 100 ) ) + val ks = secureAreaProvider.get() val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) ks.createKey( "testKey", @@ -557,7 +577,8 @@ class AndroidKeystoreSecureAreaTest { } @Test - fun testEcKeyCreationOverridesExistingAlias() { + fun testEcKeyCreationDuplicateAlias() = runTest { + val ks = secureAreaProvider.get() val challenge = byteArrayOf(1, 2, 3) val settings = AndroidKeystoreCreateKeySettings.Builder(challenge).build() ks.createKey("testKey", settings) @@ -605,7 +626,8 @@ class AndroidKeystoreSecureAreaTest { } @Throws(IOException::class) - fun testAttestationHelper(useStrongBox: Boolean) { + fun testAttestationHelper(useStrongBox: Boolean) = runTest { + val ks = secureAreaProvider.get() val validFromCalendar: Calendar = GregorianCalendar(TimeZone.getTimeZone("UTC")) validFromCalendar[2023, 5, 15, 0, 0] = 0 val validUntilCalendar: Calendar = GregorianCalendar(TimeZone.getTimeZone("UTC")) @@ -681,7 +703,8 @@ class AndroidKeystoreSecureAreaTest { } @Throws(IOException::class) - fun testAttestKeyHelper(context: Context, useStrongBox: Boolean) { + fun testAttestKeyHelper(context: Context, useStrongBox: Boolean) = runTest { + val ks = secureAreaProvider.get() val attestKeyAlias = "icTestAttestKey" val attestKeyCertificates: Array var kpg: KeyPairGenerator? = null @@ -740,7 +763,7 @@ class AndroidKeystoreSecureAreaTest { attestKeyCertificates[0].publicKey ) // expected path - } catch (e : Throwable) { + } catch (e: Throwable) { Assert.fail() } @@ -764,7 +787,8 @@ class AndroidKeystoreSecureAreaTest { @Test @Throws(IOException::class) - fun testUsingGenericCreateKeySettings() { + fun testUsingGenericCreateKeySettings() = runTest { + val ks = secureAreaProvider.get() // Challenge is always empty when using the generic CreateKeySettings val challenge = byteArrayOf() ks.createKey("testKey", CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256)) diff --git a/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt index 0ca797490..b2b9e79c1 100644 --- a/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreKeyInfo.kt @@ -10,6 +10,7 @@ import kotlinx.datetime.Instant * Android Keystore specific class for information about a key. */ class AndroidKeystoreKeyInfo internal constructor( + alias: String, publicKey: EcPublicKey, attestation: KeyAttestation, keyPurposes: Set, @@ -52,4 +53,4 @@ class AndroidKeystoreKeyInfo internal constructor( * The point in time after which the key is not valid, if set. */ val validUntil: Instant? -) : KeyInfo(publicKey, keyPurposes, attestation) \ No newline at end of file +) : KeyInfo(alias, publicKey, keyPurposes, attestation) \ No newline at end of file diff --git a/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt index 68471b3a8..efdaa082b 100644 --- a/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/android/securearea/AndroidKeystoreSecureArea.kt @@ -36,12 +36,15 @@ import com.android.identity.crypto.EcSignature import com.android.identity.crypto.javaPublicKey import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.KeyAttestation +import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.KeyInvalidatedException import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.SecureArea import com.android.identity.securearea.keyPurposeSet -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import java.io.IOException import java.nio.charset.StandardCharsets @@ -62,6 +65,7 @@ import java.security.spec.InvalidKeySpecException import java.sql.Date import javax.crypto.KeyAgreement import kotlinx.datetime.Instant +import kotlinx.io.bytestring.ByteString import org.bouncycastle.asn1.ASN1InputStream import org.bouncycastle.asn1.ASN1Integer import org.bouncycastle.asn1.ASN1Sequence @@ -102,13 +106,15 @@ import java.io.ByteArrayInputStream * what the device supports. * * This implementation works only on Android and requires API level 24 or later. + * + * Use [AndroidKeystoreSecureArea.create] to create an instance of this class. */ -class AndroidKeystoreSecureArea( +class AndroidKeystoreSecureArea private constructor( private val context: Context, - private val storageEngine: StorageEngine + private val storageTable: StorageTable ) : SecureArea { override val identifier: String - get() = "AndroidKeystoreSecureArea" + get() = IDENTIFIER override val displayName: String get() = "Android Keystore Secure Area" @@ -131,10 +137,19 @@ class AndroidKeystoreSecureArea( ) } - override fun createKey( - alias: String, + override suspend fun createKey( + alias: String?, createKeySettings: com.android.identity.securearea.CreateKeySettings - ) { + ): KeyInfo { + if (alias != null) { + // If the key with the given alias exists, it is silently overwritten. + // TODO: review if this is the semantics we want + storageTable.delete(alias) + } + + // This will throw an exception if an already-used alias is given. + val newKeyAlias = storageTable.insert(alias, ByteString()) + val aSettings: AndroidKeystoreCreateKeySettings aSettings = if (createKeySettings is AndroidKeystoreCreateKeySettings) { createKeySettings @@ -177,7 +192,7 @@ class AndroidKeystoreSecureArea( } } } - val builder = KeyGenParameterSpec.Builder(alias, purposes) + val builder = KeyGenParameterSpec.Builder(newKeyAlias, purposes) when (aSettings.ecCurve) { EcCurve.P256 -> // Works with both purposes. builder.setDigests(KeyProperties.DIGEST_SHA256) @@ -267,15 +282,17 @@ class AndroidKeystoreSecureArea( val attestationCerts = mutableListOf() try { val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) - ks.getCertificateChain(alias).forEach { certificate -> + ks.getCertificateChain(newKeyAlias).forEach { certificate -> attestationCerts.add(X509Cert(certificate.encoded)) } } catch (e: Exception) { throw IllegalStateException(e) } Logger.d(TAG, "EC key with alias '$alias' created") - saveKeyMetadata(alias, aSettings, X509CertChain(attestationCerts)) + saveKeyMetadata(newKeyAlias, aSettings, X509CertChain(attestationCerts)) + return getKeyInfo(newKeyAlias) } /** @@ -291,9 +308,14 @@ class AndroidKeystoreSecureArea( * * @param existingAlias the alias of the existing key. */ - fun createKeyForExistingAlias(existingAlias: String) { + suspend fun createKeyForExistingAlias(existingAlias: String) { + // If the key with the given alias exists, it is silently overwritten. + // TODO: review if this is the semantics we want + storageTable.delete(existingAlias) + storageTable.insert(existingAlias, ByteString()) val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) val entry = ks.getEntry(existingAlias, null) ?: throw IllegalArgumentException("A key with this alias doesn't exist") @@ -328,6 +350,7 @@ class AndroidKeystoreSecureArea( val attestationCerts = mutableListOf() try { val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) ks.getCertificateChain(existingAlias).forEach { certificate -> attestationCerts.add(X509Cert(certificate.encoded)) @@ -379,18 +402,19 @@ class AndroidKeystoreSecureArea( Logger.d(TAG, "EC existing key with alias '$existingAlias' created") } - override fun deleteKey(alias: String) { + override suspend fun deleteKey(alias: String) { val ks: KeyStore var entry: KeyStore.Entry try { ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) if (!ks.containsAlias(alias)) { Logger.w(TAG, "Key with alias '$alias' doesn't exist") return } ks.deleteEntry(alias) - storageEngine.delete(PREFIX + alias) + storageTable.delete(alias) } catch (e: CertificateException) { throw IllegalStateException("Error loading keystore", e) } catch (e: IOException) { @@ -403,7 +427,7 @@ class AndroidKeystoreSecureArea( Logger.d(TAG, "EC key with alias '$alias' deleted") } - override fun sign( + override suspend fun sign( alias: String, signatureAlgorithm: Algorithm, dataToSign: ByteArray, @@ -457,7 +481,7 @@ class AndroidKeystoreSecureArea( } @Throws(KeyLockedException::class) - override fun keyAgreement( + override suspend fun keyAgreement( alias: String, otherKey: EcPublicKey, keyUnlockData: com.android.identity.securearea.KeyUnlockData? @@ -488,20 +512,22 @@ class AndroidKeystoreSecureArea( // @throws IllegalArgumentException if the key doesn't exist. // @throws KeyInvalidatedException if LSKF was removed and the key is no longer available. - private fun loadKey(alias: String): Pair { - val data = storageEngine[PREFIX + alias] ?: throw IllegalArgumentException("No key with given alias") + private suspend fun loadKey(alias: String): Pair { + val data = storageTable.get(alias) + ?: throw IllegalArgumentException("No key with given alias") val ks = KeyStore.getInstance("AndroidKeyStore") + // TODO: move to an IO thread ks.load(null) // If the LSKF is removed, all auth-bound keys are removed and the result is // that KeyStore.getEntry() returns null. val entry = ks.getEntry(alias, null) ?: throw KeyInvalidatedException("This key is no longer available") - return Pair(entry, data) + return Pair(entry, data.toByteArray()) } - override fun getKeyInvalidated(alias: String): Boolean { + override suspend fun getKeyInvalidated(alias: String): Boolean { try { loadKey(alias) } catch (e: KeyInvalidatedException) { @@ -510,7 +536,7 @@ class AndroidKeystoreSecureArea( return false } - override fun getKeyInfo(alias: String): AndroidKeystoreKeyInfo { + override suspend fun getKeyInfo(alias: String): AndroidKeystoreKeyInfo { val (entry, data) = loadKey(alias) return try { val privateKey = (entry as KeyStore.PrivateKeyEntry).privateKey @@ -552,6 +578,7 @@ class AndroidKeystoreSecureArea( userAuthenticationTypes.add(UserAuthenticationType.BIOMETRIC) } AndroidKeystoreKeyInfo( + alias, publicKey, KeyAttestation(publicKey, attestationCertChain), keyPurposes, @@ -568,7 +595,7 @@ class AndroidKeystoreSecureArea( } } - private fun saveKeyMetadata( + private suspend fun saveKeyMetadata( alias: String, settings: AndroidKeystoreCreateKeySettings, attestation: X509CertChain @@ -583,7 +610,7 @@ class AndroidKeystoreSecureArea( map.put("useStrongBox", settings.useStrongBox) map.put("attestation", attestation.toDataItem()) map.put("curve", settings.ecCurve.coseCurveIdentifier) - storageEngine.put(PREFIX + alias, Cbor.encode(map.end().build())) + storageTable.update(alias, ByteString(Cbor.encode(map.end().build()))) } /** @@ -698,8 +725,22 @@ class AndroidKeystoreSecureArea( */ private const val TAG = "AndroidKeystoreSA" // limit to <= 23 chars - // Prefix used for storage items, the key alias follows. - private const val PREFIX = "IC_AndroidKeystore_" + const val IDENTIFIER = "AndroidKeystoreSecureArea" + + /** + * Creates an instance of AndroidKeystoreSecureArea. + * + * @param storage the storage engine to use for storing key material. + */ + suspend fun create(context: Context, storage: Storage): AndroidKeystoreSecureArea { + return AndroidKeystoreSecureArea(context, storage.getTable(tableSpec)) + } + + private val tableSpec = StorageTableSpec( + name = "AndroidKeystoreSecureArea", + supportPartitions = false, + supportExpiration = false + ) internal fun getSignatureAlgorithmName(signatureAlgorithm: Algorithm): String { return when (signatureAlgorithm) { diff --git a/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt b/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt index fe280af18..e789ad99a 100644 --- a/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/device/DeviceCheck.android.kt @@ -17,13 +17,11 @@ actual object DeviceCheck { secureArea: SecureArea, clientId: String ): DeviceAttestationResult { - val alias = "deviceCheck_" + Random.nextBytes(9).toBase64Url() val keySettings = AndroidKeystoreCreateKeySettings.Builder(clientId.encodeToByteArray()) .build() - secureArea.createKey(alias, keySettings) - val keyInfo = secureArea.getKeyInfo(alias) + val keyInfo = secureArea.createKey(null, keySettings) return DeviceAttestationResult( - deviceAttestationId = alias, + deviceAttestationId = keyInfo.alias, deviceAttestation = DeviceAttestationAndroid(keyInfo.attestation.certChain!!) ) } diff --git a/identity/src/androidMain/kotlin/com/android/identity/storage/android/AndroidStorage.kt b/identity/src/androidMain/kotlin/com/android/identity/storage/android/AndroidStorage.kt index 9543d83df..6e062c571 100644 --- a/identity/src/androidMain/kotlin/com/android/identity/storage/android/AndroidStorage.kt +++ b/identity/src/androidMain/kotlin/com/android/identity/storage/android/AndroidStorage.kt @@ -22,7 +22,7 @@ class AndroidStorage: BaseStorage { constructor( database: SQLiteDatabase, - clock: Clock, + clock: Clock = Clock.System, coroutineContext: CoroutineContext = Dispatchers.IO, keySize: Int = 9 ): super(clock) { @@ -34,7 +34,7 @@ class AndroidStorage: BaseStorage { constructor( databasePath: String?, - clock: Clock, + clock: Clock = Clock.System, coroutineContext: CoroutineContext = Dispatchers.IO, keySize: Int = 9 ): super(clock) { diff --git a/identity/src/commonMain/kotlin/com/android/identity/cose/Cose.kt b/identity/src/commonMain/kotlin/com/android/identity/cose/Cose.kt index d2c20d181..96afa0ea9 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/cose/Cose.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/cose/Cose.kt @@ -177,7 +177,7 @@ object Cose { * @param unprotectedHeaders the unprotected headers to include. * @param keyUnlockData a [KeyUnlockData] for unlocking the key in the [SecureArea]. */ - fun coseSign1Sign( + suspend fun coseSign1Sign( secureArea: SecureArea, alias: String, message: ByteArray, diff --git a/identity/src/commonMain/kotlin/com/android/identity/credential/Credential.kt b/identity/src/commonMain/kotlin/com/android/identity/credential/Credential.kt index bdaf25c0b..af4b9e98f 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/credential/Credential.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/credential/Credential.kt @@ -88,7 +88,7 @@ open class Credential { * @param asReplacementFor the credential this credential will replace, if not null. * @param domain the domain of the credential. */ - constructor( + protected constructor( document: Document, asReplacementFor: Credential?, domain: String @@ -101,44 +101,41 @@ open class Credential { this.document = document replacementForIdentifier = asReplacementFor?.identifier asReplacementFor?.replacementIdentifier = this.identifier - _applicationData = SimpleApplicationData { document.saveDocument() } - // Only the leaf constructor should add the credential to the document. - if (this::class == Credential::class) { - addToDocument() - } + _applicationData = SimpleApplicationData { document.requestSave() } } - // Should only be called by the leaf constructor, like this - // - // if (this::class == MyCredentialClass::class) { - // addToDocument() - // } - // - // where MyCredentialClass is a class derived from Credential. - // + /** + * Should be called by derived class either in constructor for a new credential (for keyless + * credentials) or once the key is generated (for key-bound credentials). + */ protected fun addToDocument() { credentialCounter = document.addCredential(this) - document.saveDocument() + document.requestSave() } /** * Constructs a Credential from serialized data. * + * [deserialize] providing actual serialized data must be called before using this object. + * * @param document the [Document] that the credential belongs to. - * @param dataItem the serialized data. */ - constructor( + protected constructor( document: Document, - dataItem: DataItem ) { + this.document = document + } + + /** + * Initialize this object using serialized data. + */ + open suspend fun deserialize(dataItem: DataItem) { val applicationDataDataItem = dataItem["applicationData"] check(applicationDataDataItem is Bstr) { "applicationData not found or not byte[]" } - this.document = document _applicationData = SimpleApplicationData .decodeFromCbor(applicationDataDataItem.value) { - document.saveDocument() + document.requestSave() } - identifier = dataItem["identifier"].asTstr domain = dataItem["domain"].asTstr usageCount = dataItem["usageCount"].asNumber.toInt() @@ -158,7 +155,6 @@ open class Credential { replacementForIdentifier = dataItem.getOrNull("replacementForAlias")?.asTstr credentialCounter = dataItem["credentialCounter"].asNumber - } // Creates an alias which is guaranteed to be unique for all time (assuming the system clock @@ -171,12 +167,14 @@ open class Credential { /** * A stable identifier for the Credential instance. */ - val identifier: String + lateinit var identifier: String + private set /** * The domain of the credential. */ - val domain: String + lateinit var domain: String + private set /** * How many times the credential has been used in an identity presentation. @@ -193,7 +191,7 @@ open class Credential { /** * Indicates whether the credential has been invalidated. */ - open val isInvalidated = false + open suspend fun isInvalidated(): Boolean = false /** * Application specific data. @@ -204,7 +202,7 @@ open class Credential { */ val applicationData: ApplicationData get() = _applicationData - private val _applicationData: SimpleApplicationData + private lateinit var _applicationData: SimpleApplicationData /** * The issuer-provided data associated with the credential. @@ -287,7 +285,7 @@ open class Credential { * * After deletion, this object should no longer be used. */ - open fun delete() { + open suspend fun delete() { document.removeCredential(this) } @@ -296,9 +294,9 @@ open class Credential { * * This should be called when a crdential has been presented to a verifier. */ - fun increaseUsageCount() { + suspend fun increaseUsageCount() { usageCount += 1 - document.saveDocument() + document.saveDocument(false) } /** @@ -308,7 +306,7 @@ open class Credential { * @param validFrom the point in time before which the data is not valid. * @param validUntil the point in time after which the data is not valid. */ - fun certify( + suspend fun certify( issuerProvidedAuthenticationData: ByteArray, validFrom: Instant, validUntil: Instant diff --git a/identity/src/commonMain/kotlin/com/android/identity/credential/CredentialFactory.kt b/identity/src/commonMain/kotlin/com/android/identity/credential/CredentialFactory.kt index 543bf720a..81b746ab6 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/credential/CredentialFactory.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/credential/CredentialFactory.kt @@ -29,7 +29,7 @@ import kotlin.reflect.KClass */ class CredentialFactory { private val createCredentialFunctions: - MutableMap Credential> = mutableMapOf() + MutableMap Credential> = mutableMapOf() /** * Add a new [Credential] implementation to the repository. @@ -39,7 +39,7 @@ class CredentialFactory { */ fun addCredentialImplementation( credentialType: KClass, - createCredentialFunction: (Document, DataItem) -> Credential + createCredentialFunction: suspend (Document, DataItem) -> Credential ) = createCredentialFunctions.put(credentialType.simpleName!!, createCredentialFunction) /** @@ -50,12 +50,10 @@ class CredentialFactory { * @return a credential instance * @throws IllegalStateException if there is no registered type for the serialized data. */ - fun createCredential(document: Document, dataItem: DataItem): Credential { + suspend fun createCredential(document: Document, dataItem: DataItem): Credential { val credentialType = dataItem["credentialType"].asTstr - val createCredentialFunction = createCredentialFunctions.get(credentialType) - if (createCredentialFunction == null) { - throw IllegalStateException("Credential type $credentialType not registered") - } + val createCredentialFunction = createCredentialFunctions[credentialType] + ?: throw IllegalStateException("Credential type $credentialType not registered") return createCredentialFunction.invoke(document, dataItem) } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/credential/SecureAreaBoundCredential.kt b/identity/src/commonMain/kotlin/com/android/identity/credential/SecureAreaBoundCredential.kt index e84012ec1..cf7db7d09 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/credential/SecureAreaBoundCredential.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/credential/SecureAreaBoundCredential.kt @@ -38,38 +38,46 @@ open class SecureAreaBoundCredential : Credential { /** * Constructs a new [SecureAreaBoundCredential]. * + * [generateKey] providing [CreateKeySettings] must be called before using this object. + * * @param document the document to add the credential to. * @param asReplacementFor the credential this credential will replace, if not null * @param domain the domain of the credential * @param secureArea the secure area for the authentication key associated with this credential. - * @param createKeySettings the settings used to create new credentials. */ constructor( document: Document, asReplacementFor: Credential?, domain: String, secureArea: SecureArea, - createKeySettings: CreateKeySettings, ) : super(document, asReplacementFor, domain) { this.secureArea = secureArea - this.alias = SECURE_AREA_ALIAS_PREFIX + identifier - this.secureArea.createKey(alias, createKeySettings) - // Only the leaf constructor should add the credential to the document. - if (this::class == SecureAreaBoundCredential::class) { - addToDocument() - } + } + + /** + * Generates an authentication key to which this credential is bound and adds the credential + * to the document. + * + * @param createKeySettings [CreateKeySettings] that are used to create the key. + */ + suspend fun generateKey(createKeySettings: CreateKeySettings) { + alias = secureArea.createKey(null, createKeySettings).alias + addToDocument() } /** * Constructs a Credential from serialized data. * * @param document the [Document] that the credential belongs to. - * @param dataItem the serialized data. + * + * [deserialize] must be called before using this object. */ constructor( document: Document, - dataItem: DataItem, - ) : super(document, dataItem) { + ) : super(document) + + override suspend fun deserialize(dataItem: DataItem) { + super.deserialize(dataItem) alias = dataItem["alias"].asTstr val secureAreaIdentifier = dataItem["secureAreaIdentifier"].asTstr secureArea = document.secureAreaRepository.getImplementation(secureAreaIdentifier) @@ -81,17 +89,17 @@ open class SecureAreaBoundCredential : Credential { * * This can be used together with the alias returned by [alias]. */ - val secureArea: SecureArea + lateinit var secureArea: SecureArea + private set /** * The alias for the authentication key associated with this credential. * * This can be used together with the [SecureArea] returned by [secureArea] */ - val alias: String + lateinit var alias: String - override val isInvalidated: Boolean - get() = secureArea.getKeyInvalidated(alias) + override suspend fun isInvalidated(): Boolean = secureArea.getKeyInvalidated(alias) /** * The attestation for the [SecureArea] key associated with this credential. @@ -101,10 +109,9 @@ open class SecureAreaBoundCredential : Credential { * Once received, the application should call [Credential.certify] to certify * the [Credential]. */ - val attestation: KeyAttestation - get() = secureArea.getKeyInfo(alias).attestation + suspend fun getAttestation(): KeyAttestation = secureArea.getKeyInfo(alias).attestation - override fun delete() { + override suspend fun delete() { secureArea.deleteKey(alias) super.delete() } diff --git a/identity/src/commonMain/kotlin/com/android/identity/document/Document.kt b/identity/src/commonMain/kotlin/com/android/identity/document/Document.kt index 8e7cd9476..a5d7c0456 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/document/Document.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/document/Document.kt @@ -21,12 +21,17 @@ import com.android.identity.credential.Credential import com.android.identity.credential.CredentialFactory import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository +import com.android.identity.storage.NoRecordStorageException import com.android.identity.storage.StorageEngine +import com.android.identity.storage.StorageTable import com.android.identity.util.ApplicationData import com.android.identity.util.Logger import com.android.identity.util.SimpleApplicationData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import kotlinx.io.bytestring.ByteString /** * This class represents a document created in [DocumentStore]. @@ -90,16 +95,17 @@ import kotlinx.datetime.Instant */ class Document private constructor( val name: String, - private val storageEngine: StorageEngine, + private val storageTable: StorageTable, val secureAreaRepository: SecureAreaRepository, private val store: DocumentStore, private val credentialFactory: CredentialFactory ) { private var addedToStore = false + private var deleted = false - internal fun addToStore() { + internal suspend fun addToStore() { addedToStore = true - saveDocument() + saveDocument(true) } /** @@ -109,9 +115,8 @@ class Document private constructor( * with the credential. Setters and associated getters are * enumerated in the [ApplicationData] interface. */ - private var _applicationData = SimpleApplicationData { saveDocument() } - val applicationData: ApplicationData - get() = _applicationData + private var _applicationData = SimpleApplicationData { requestSave() } + val applicationData: ApplicationData get() = _applicationData /** * Credentials which still need to be certified @@ -119,7 +124,9 @@ class Document private constructor( private var _pendingCredentials = mutableListOf() val pendingCredentials: List // Return shallow copy b/c backing field may get modified if certify() or delete() is called. - get() = _pendingCredentials.toList() + get() { + return _pendingCredentials.toList() + } /** * Certified credentials. @@ -127,7 +134,9 @@ class Document private constructor( private var _certifiedCredentials = mutableListOf() val certifiedCredentials: List // Return shallow copy b/c backing field may get modified if certify() or delete() is called. - get() = _certifiedCredentials.toList() + get() { + return _certifiedCredentials.toList() + } /** * Credential counter. @@ -137,7 +146,27 @@ class Document private constructor( var credentialCounter: Long = 0 private set - internal fun saveDocument() { + internal fun requestSave() { + if (!addedToStore) { + return + } + // TODO: batch it a bit + store.coroutineScope.launch { + try { + saveDocument(false) + } catch (err: NoRecordStorageException) { + // Document was removed or never added to the store? + if (deleted || !addedToStore) { + Logger.i(TAG, + "Attempt to save a document '$name' that was not added to the store or was deleted") + } else { + Logger.e(TAG, "Consistency error: document '$name' does not exist anymore") + } + } + } + } + + internal suspend fun saveDocument(initialSave: Boolean) { if (!addedToStore) { return } @@ -154,7 +183,12 @@ class Document private constructor( } put("credentialCounter", credentialCounter) } - storageEngine.put(DOCUMENT_PREFIX + name, Cbor.encode(mapBuilder.end().build())) + val bytes = ByteString(Cbor.encode(mapBuilder.end().build())) + if (initialSave) { + storageTable.insert(name, bytes) + } else { + storageTable.update(name, bytes) + } val t1 = Clock.System.now() // Saving a document is a costly affair (often more than 100ms) so log when we're doing @@ -163,16 +197,18 @@ class Document private constructor( // credentials. val durationMillis = t1.toEpochMilliseconds() - t0.toEpochMilliseconds() Logger.i(TAG, "Saved document '$name' to disk in $durationMillis msec") - store.emitOnDocumentChanged(this) + if (!initialSave) { + store.emitOnDocumentChanged(this) + } } - private fun loadDocument(): Boolean { - val data = storageEngine[DOCUMENT_PREFIX + name] ?: return false - val map = Cbor.decode(data) + private suspend fun loadDocument(): Boolean { + val data = storageTable.get(name) ?: return false + val map = Cbor.decode(data.toByteArray()) _applicationData = SimpleApplicationData .decodeFromCbor(map["applicationData"].asBstr) { - saveDocument() + requestSave() } _pendingCredentials = ArrayList() @@ -188,10 +224,11 @@ class Document private constructor( return true } - internal fun deleteDocument() { + internal suspend fun deleteDocument() { _pendingCredentials.clear() _certifiedCredentials.clear() - storageEngine.delete(DOCUMENT_PREFIX + name) + storageTable.delete(name) + deleted = true } /** @@ -237,7 +274,7 @@ class Document private constructor( /** * Goes through all credentials and deletes the ones which are invalidated. */ - fun deleteInvalidatedCredentials() { + suspend fun deleteInvalidatedCredentials() { for (pendingCredential in pendingCredentials) { deleteIfInvalidated(pendingCredential, "pending credential") } @@ -246,9 +283,9 @@ class Document private constructor( } } - private fun deleteIfInvalidated(credential: Credential, credentialType: String = "credential") { + private suspend fun deleteIfInvalidated(credential: Credential, credentialType: String = "credential") { try { - if (credential.isInvalidated) { + if (credential.isInvalidated()) { Logger.i(TAG, "Deleting invalidated $credentialType ${credential.identifier}") credential.delete() } @@ -312,7 +349,7 @@ class Document private constructor( return UsableCredentialResult(numCredentials, numCredentialsAvailable) } - internal fun removeCredential(credential: Credential) { + internal suspend fun removeCredential(credential: Credential) { val listToModify = if (credential.isCertified) _certifiedCredentials else _pendingCredentials check(listToModify.remove(credential)) { "Error removing credential" } @@ -334,7 +371,7 @@ class Document private constructor( } } } - saveDocument() + saveDocument(false) } /** @@ -342,38 +379,38 @@ class Document private constructor( * * @param credential The credential to certify. */ - internal fun certifyPendingCredential( + internal suspend fun certifyPendingCredential( credential: Credential ): Credential { check(_pendingCredentials.remove(credential)) { "Error removing credential from pending list" } _certifiedCredentials.add(credential) - saveDocument() + saveDocument(false) return credential } companion object { private const val TAG = "Document" - internal const val DOCUMENT_PREFIX = "IC_Document_" - internal const val AUTHENTICATION_KEY_ALIAS_PREFIX = "IC_Credential_" // Called by DocumentStore.createDocument(). - internal fun create( - storageEngine: StorageEngine, + internal suspend fun create( + storageTable: StorageTable, secureAreaRepository: SecureAreaRepository, name: String, store: DocumentStore, credentialFactory: CredentialFactory ): Document = - Document(name, storageEngine, secureAreaRepository, store, credentialFactory).apply { saveDocument() } + Document(name, storageTable, secureAreaRepository, store, credentialFactory).apply { + saveDocument(true) + } // Called by DocumentStore.lookupDocument(). - internal fun lookup( - storageEngine: StorageEngine, + internal suspend fun lookup( + storageTable: StorageTable, secureAreaRepository: SecureAreaRepository, name: String, store: DocumentStore, credentialFactory: CredentialFactory - ): Document? = Document(name, storageEngine, secureAreaRepository, store, credentialFactory).run { + ): Document? = Document(name, storageTable, secureAreaRepository, store, credentialFactory).run { try { if (!loadDocument()) { return null diff --git a/identity/src/commonMain/kotlin/com/android/identity/document/DocumentStore.kt b/identity/src/commonMain/kotlin/com/android/identity/document/DocumentStore.kt index 0c698195e..abdb43bc8 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/document/DocumentStore.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/document/DocumentStore.kt @@ -15,14 +15,21 @@ */ package com.android.identity.document +import com.android.identity.credential.Credential import com.android.identity.credential.CredentialFactory import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTableSpec +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext /** * Class for storing real-world identity documents. @@ -44,19 +51,23 @@ import kotlinx.coroutines.runBlocking * For more details about documents stored in a [DocumentStore] see the * [Document] class. * - * @param storageEngine the [StorageEngine] to use for storing/retrieving documents. + * @param storage the [Storage] to use for storing/retrieving documents. * @param secureAreaRepository the repository of configured [SecureArea] that can * be used. * @param credentialFactory the [CredentialFactory] to use for retrieving serialized credentials * associated with documents. */ class DocumentStore( - private val storageEngine: StorageEngine, + storage: Storage, private val secureAreaRepository: SecureAreaRepository, - private val credentialFactory: CredentialFactory + private val credentialFactory: CredentialFactory, + internal val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default) ) { // Use a cache so the same instance is returned by multiple lookupDocument() calls. private val documentCache = mutableMapOf() + private val storageTableHolder = coroutineScope.async { + storage.getTable(tableSpec) + } /** * Creates a new document. @@ -72,14 +83,14 @@ class DocumentStore( * @param name an identifier for the document. * @return A newly created document. */ - fun createDocument(name: String): Document { + suspend fun createDocument(name: String): Document { lookupDocument(name)?.let { document -> documentCache.remove(name) emitOnDocumentDeleted(document) document.deleteDocument() } val transientDocument = Document.create( - storageEngine, + storageTableHolder.await(), secureAreaRepository, name, this, @@ -91,11 +102,11 @@ class DocumentStore( /** * Adds a document created with [createDocument] to the document store. * - * This makes the document visible to collectors collecing from [eventFlow]. + * This makes the document visible to collectors collecting from [eventFlow]. * * @param document the document. */ - fun addDocument(document: Document) { + suspend fun addDocument(document: Document) { document.addToStore() documentCache[document.name] = document emitOnDocumentAdded(document) @@ -107,10 +118,10 @@ class DocumentStore( * @param name the identifier of the document. * @return the document or `null` if not found. */ - fun lookupDocument(name: String): Document? { + suspend fun lookupDocument(name: String): Document? { val result = documentCache[name] - ?: Document.lookup(storageEngine, secureAreaRepository, name, this, credentialFactory) + ?: Document.lookup(storageTableHolder.await(), secureAreaRepository, name, this, credentialFactory) ?: return null documentCache[name] = result return result @@ -121,11 +132,9 @@ class DocumentStore( * * @return list of all the document names in the store. */ - fun listDocuments(): List = mutableListOf().apply { - storageEngine.enumerate() - .filter { name -> name.startsWith(Document.DOCUMENT_PREFIX) } - .map { name -> name.substring(Document.DOCUMENT_PREFIX.length) } - .forEach { name -> add(name) } + suspend fun listDocuments(): List { + // right now lock is not required + return storageTableHolder.await().enumerate() } /** @@ -135,7 +144,7 @@ class DocumentStore( * * @param name the identifier of the document. */ - fun deleteDocument(name: String) { + suspend fun deleteDocument(name: String) { lookupDocument(name)?.let { document -> documentCache.remove(name) emitOnDocumentDeleted(document) @@ -173,30 +182,30 @@ class DocumentStore( get() = _eventFlow.asSharedFlow() - private fun emitOnDocumentAdded(document: Document) { - runBlocking { - _eventFlow.emit(Pair(EventType.DOCUMENT_ADDED, document)) - } + private suspend fun emitOnDocumentAdded(document: Document) { + _eventFlow.emit(Pair(EventType.DOCUMENT_ADDED, document)) } - private fun emitOnDocumentDeleted(document: Document) { - runBlocking { - _eventFlow.emit(Pair(EventType.DOCUMENT_DELETED, document)) - } + private suspend fun emitOnDocumentDeleted(document: Document) { + _eventFlow.emit(Pair(EventType.DOCUMENT_DELETED, document)) } // Called by code in Document class - internal fun emitOnDocumentChanged(document: Document) { + internal suspend fun emitOnDocumentChanged(document: Document) { if (documentCache[document.name] == null) { // This is to prevent emitting onChanged when creating a document. return } - runBlocking { - _eventFlow.emit(Pair(EventType.DOCUMENT_UPDATED, document)) - } + _eventFlow.emit(Pair(EventType.DOCUMENT_UPDATED, document)) } companion object { const val TAG = "DocumentStore" + + private val tableSpec = StorageTableSpec( + name = "DocumentStore", + supportPartitions = false, + supportExpiration = false + ) } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/document/DocumentUtil.kt b/identity/src/commonMain/kotlin/com/android/identity/document/DocumentUtil.kt index 751fd4187..c86b566cc 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/document/DocumentUtil.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/document/DocumentUtil.kt @@ -53,10 +53,10 @@ object DocumentUtil { * @param dryRun don't actually create the credentials, just return how many would be created. * @return the number of credentials created. */ - fun managedCredentialHelper( + suspend fun managedCredentialHelper( document: Document, domain: String, - createCredential: ((credentialToReplace: Credential?) -> Credential)?, + createCredential: (suspend (credentialToReplace: Credential?) -> Credential)?, now: Instant, numCredentials: Int, maxUsesPerCredential: Int, diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/KeyInfo.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/KeyInfo.kt index 1d89a1a48..3d2e6e063 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/securearea/KeyInfo.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/KeyInfo.kt @@ -13,6 +13,7 @@ import com.android.identity.crypto.EcPublicKey * @param attestation the attestation for the key. */ open class KeyInfo protected constructor( + val alias: String, val publicKey: EcPublicKey, val keyPurposes: Set, val attestation: KeyAttestation diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureArea.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureArea.kt index 03c296181..d5e6686ce 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureArea.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureArea.kt @@ -64,12 +64,14 @@ interface SecureArea { * If an existing key with the given alias already exists it will be * replaced by the new key. * - * @param alias A unique string to identify the newly created key. + * @param alias A unique string to identify the newly created key. When null is passed, + * a new unique alias is generated automatically * @param createKeySettings A [CreateKeySettings] object. * @throws IllegalArgumentException if the underlying Secure Area Implementation * does not support the requested creation settings, for example the EC curve to use. + * @return key alias */ - fun createKey(alias: String, createKeySettings: CreateKeySettings) + suspend fun createKey(alias: String?, createKeySettings: CreateKeySettings): KeyInfo /** * Deletes a previously created key. @@ -78,7 +80,7 @@ interface SecureArea { * * @param alias The alias of the EC key to delete. */ - fun deleteKey(alias: String) + suspend fun deleteKey(alias: String) /** * Signs data with a key. @@ -98,7 +100,7 @@ interface SecureArea { * @throws KeyLockedException if the key needs unlocking. * @throws KeyInvalidatedException if the key is no longer usable. */ - fun sign( + suspend fun sign( alias: String, signatureAlgorithm: Algorithm, dataToSign: ByteArray, @@ -122,7 +124,7 @@ interface SecureArea { * @throws KeyLockedException if the key needs unlocking. * @throws KeyInvalidatedException if the key is no longer usable. */ - fun keyAgreement( + suspend fun keyAgreement( alias: String, otherKey: EcPublicKey, keyUnlockData: KeyUnlockData? @@ -136,7 +138,7 @@ interface SecureArea { * @throws IllegalArgumentException if there is no key with the given alias. * @throws KeyInvalidatedException if the key is invalidated. */ - fun getKeyInfo(alias: String): KeyInfo + suspend fun getKeyInfo(alias: String): KeyInfo /** * Checks whether the key has been invalidated. @@ -145,5 +147,5 @@ interface SecureArea { * @return `true` if the key has been invalidated, `false` otherwise. * @throws IllegalArgumentException if there is no key with the given alias. */ - fun getKeyInvalidated(alias: String): Boolean + suspend fun getKeyInvalidated(alias: String): Boolean } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaProvider.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaProvider.kt new file mode 100644 index 000000000..e06390662 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaProvider.kt @@ -0,0 +1,38 @@ +package com.android.identity.securearea + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlin.coroutines.CoroutineContext + +/** + * Lazily creates a [SecureArea]. + * + * [SecureArea] creation is typically asynchronous and cannot be completed in a + * synchronous scopes (such as at static initialization or callbacks like `onCreate`). + * This class provides a wrapper to hold an asynchronously-created [SecureArea]. To create + * a [SecureArea] in synchronous context, use + * ``` + * // synchronous initialization code + * val fooSecureAreaProvider = SecureAreaProvider { FooSecureArea.create(storage, ...) } + * ``` + * + * Then when using secure area in an asynchronous context (which is required as [SecureArea] APIs + * are asynchronous): + * ``` + * // asynchronous code + * val fooSecureArea = fooSecureAreaProvider.get() + * fooSecureArea.someApiCall() + * ``` + */ +class SecureAreaProvider( + context: CoroutineContext = Dispatchers.Main, + provider: suspend CoroutineScope.() -> T +) { + private val deferred: Deferred = + CoroutineScope(context).async(context, CoroutineStart.LAZY, provider) + + suspend fun get(): T = deferred.await() +} \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaRepository.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaRepository.kt index 059cdfbb0..bacb5f29d 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaRepository.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/SecureAreaRepository.kt @@ -16,6 +16,14 @@ package com.android.identity.securearea import com.android.identity.util.Logger +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlin.coroutines.CoroutineContext /** * A repository of [SecureArea] implementations. @@ -23,21 +31,9 @@ import com.android.identity.util.Logger * This is used by to provide fine-grained control for which [SecureArea] * implementation to use when loading keys and objects using different implementations. */ -class SecureAreaRepository { - companion object { - private const val TAG = "SecureAreaRepository" - } - - private var _implementations = mutableListOf() - - private val implementationFactories = mutableMapOf SecureArea?>() - - /** - * All [SecureArea] implementations in the repository. - */ - val implementations: List - get() = _implementations - +class SecureAreaRepository private constructor( + private val holder: Deferred +) { /** * Gets a [SecureArea] by identifier * @@ -46,58 +42,93 @@ class SecureAreaRepository { * @param identifier the identifier for the Secure Area. * @return the implementation or `null` if no implementation has been registered. */ - fun getImplementation(identifier: String): SecureArea? { - var result = _implementations.firstOrNull { it.identifier == identifier } - if (result != null) { - return result + suspend fun getImplementation(identifier: String): SecureArea? { + val holder = this.holder.await() + val secureAreaDeferred = holder.lock.withLock { + val existing = holder.secureAreaByIdentifier[identifier] + if (existing != null) { + existing + } else { + val questionMarkLocation = identifier.indexOf('?') + if (questionMarkLocation == -1) { + return null + } + val identifierPrefix = identifier.substring(0, questionMarkLocation) + val factoryFunc = holder.implementationFactories[identifierPrefix] ?: return null + val deferred = CoroutineScope(holder.context).async { + // NB: lock is not held in this scope! + val newSecureArea = factoryFunc(identifier) + if (newSecureArea != null && identifier != newSecureArea.identifier) { + Logger.e(TAG, "Requested identifier `$identifier` got `${newSecureArea.identifier}`") + } + newSecureArea + } + holder.secureAreaByIdentifier[identifier] = deferred + deferred + } } + return secureAreaDeferred.await() + } - val questionMarkLocation = identifier.indexOf('?') - if (questionMarkLocation == -1) { - return null - } + internal class SecureAreaHolder( + internal val context: CoroutineContext, + // protected by lock + internal val secureAreaByIdentifier: MutableMap>, + internal val implementationFactories: Map SecureArea?> + ) { + internal val lock = Mutex() + } - val identifierPrefix = identifier.substring(0, questionMarkLocation) - val factoryFunc = implementationFactories[identifierPrefix] - if (factoryFunc == null) { - return null + class Builder(private val context: CoroutineContext) { + private val byIdentifier = mutableMapOf>() + private val factories = mutableMapOf SecureArea?>() + + /** + * Adds a Secure Area factory that can be used for Secure Areas where it's possible to + * have multiple instances. For example for a `CloudSecureArea` the URL of the server + * is part of the identifier e.g. + * `CloudSecureArea?id=SOME_UNIQUE_ID&url=https://csa.example.com/server`. An application + * can use this method to register a factory for establishing a connection to the + * requested URL and configure the instance as needed. Key to the map is Secure Area + * identifier prefix of the Secure Area identifier up until the '?' character. + * @param identifierPrefix part of the identifier before '?' character for the secure + * areas created by the factory + * @param factory a function to create a new Secure Area with the requested identifier. + */ + fun addFactory(identifierPrefix: String, factory: suspend (String) -> SecureArea?) { + if (factories.contains(identifierPrefix)) { + throw IllegalArgumentException("Duplicate SecureArea factory: $identifierPrefix") + } + factories[identifierPrefix] = factory } - result = factoryFunc(identifier) - if (result != null) { - if (identifier != result.identifier) { - Logger.w(TAG, "Requested identifier `$identifier` got `${result.identifier}`") + /** + * Adds a Secure Area implementation. + */ + fun add(secureArea: SecureArea) { + if (byIdentifier.contains(secureArea.identifier)) { + throw IllegalArgumentException("Duplicate SecureArea: ${secureArea.identifier}") } - _implementations.add(result) - return result + byIdentifier[secureArea.identifier] = CompletableDeferred(secureArea) } - return null + internal fun build(): SecureAreaHolder { + return SecureAreaHolder(context, byIdentifier, factories.toMap()) + } } - /** - * Adds a [SecureArea] to the repository. - * - * @param secureArea an instance of a type implementing the [SecureArea] interface. - */ - fun addImplementation(secureArea: SecureArea) = _implementations.add(secureArea) + companion object { + private const val TAG = "SecureAreaRepository" - /** - * Adds a factory function to create a [SecureArea] on demand. - * - * This can be used for Secure Areas where it's possible to have multiple instances. For - * example for a [CloudSecureArea] the URL of the server is part of the identifier e.g. - * `CloudSecureArea?id=SOME_UNIQUE_ID&url=https://csa.example.com/server`. An application - * can use this method to register a factory for establishing a connection to the - * requested URL and configure the instance as needed. - * - * @param identifierPrefix prefix of the Secure Area identifier up until the '?' character. - * @param factoryFunc a function to create a new Secure Area with the requested identifier. - */ - fun addImplementationFactory( - identifierPrefix: String, - factoryFunc: (identifier: String) -> SecureArea? - ) { - implementationFactories.put(identifierPrefix, factoryFunc) + fun build( + context: CoroutineContext = Dispatchers.Default, + block: suspend Builder.() -> Unit + ): SecureAreaRepository { + val builder = Builder(context) + return SecureAreaRepository(CoroutineScope(context).async { + builder.block() + builder.build() + }) + } } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareKeyInfo.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareKeyInfo.kt index fc248f637..88882805d 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareKeyInfo.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareKeyInfo.kt @@ -13,12 +13,14 @@ import com.android.identity.securearea.PassphraseConstraints * @param passphraseConstraints constraints on the passphrase, if any. */ class SoftwareKeyInfo internal constructor( + alias: String, publicKey: EcPublicKey, attestation: KeyAttestation, keyPurposes: Set, val isPassphraseProtected: Boolean, val passphraseConstraints: PassphraseConstraints? ): KeyInfo( + alias, publicKey, keyPurposes, attestation diff --git a/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareSecureArea.kt b/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareSecureArea.kt index 8b2785013..2493120d0 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareSecureArea.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/securearea/software/SoftwareSecureArea.kt @@ -23,6 +23,7 @@ import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.EcSignature import com.android.identity.securearea.KeyAttestation +import com.android.identity.securearea.KeyInfo import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.KeyPurpose.Companion.encodeSet @@ -32,7 +33,10 @@ import com.android.identity.securearea.SecureArea import com.android.identity.securearea.fromDataItem import com.android.identity.securearea.keyPurposeSet import com.android.identity.securearea.toDataItem -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec +import kotlinx.io.bytestring.ByteString import kotlin.random.Random /** @@ -40,7 +44,7 @@ import kotlin.random.Random * * This implementation supports all the curves and algorithms defined by [SecureArea] * and also supports passphrase-protected keys. Key material is stored using the - * [StorageEngine] abstraction and passphrase-protected keys are encrypted using + * [Storage] abstraction and passphrase-protected keys are encrypted using * [AES-GCM](https://en.wikipedia.org/wiki/Galois/Counter_Mode) * with 256-bit keys with the key derived from the passphrase using * [HKDF](https://en.wikipedia.org/wiki/HKDF). @@ -49,17 +53,23 @@ import kotlin.random.Random * [Bouncy Castle](https://www.bouncycastle.org/) library but this implementation * detail may change in the future. * - * @param storageEngine the storage engine to use for storing key material. + * Use [SoftwareSecureArea.create] to create an instance of SoftwareSecureArea. */ -class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea { - override val identifier get() = "SoftwareSecureArea" +class SoftwareSecureArea private constructor(private val storageTable: StorageTable) : SecureArea { + override val identifier get() = IDENTIFIER override val displayName get() = "Software Secure Area" - override fun createKey( - alias: String, + override suspend fun createKey( + alias: String?, createKeySettings: com.android.identity.securearea.CreateKeySettings - ) { + ): KeyInfo { + if (alias != null) { + // If the key with the given alias exists, it is silently overwritten. + // TODO: review if this is the semantics we want + storageTable.delete(alias) + } + val settings = if (createKeySettings is SoftwareCreateKeySettings) { createKeySettings } else { @@ -100,7 +110,11 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea if (settings.passphraseConstraints != null) { mapBuilder.put("passphraseConstraints", settings.passphraseConstraints.toDataItem()) } - storageEngine.put(PREFIX + alias, Cbor.encode(mapBuilder.end().build())) + val newAlias = storageTable.insert( + key = alias, + data = ByteString(Cbor.encode(mapBuilder.end().build())) + ) + return getKeyInfo(newAlias) } catch (e: Exception) { // such as NoSuchAlgorithmException, CertificateException, InvalidAlgorithmParameterException, OperatorCreationException, IOException, NoSuchProviderException throw IllegalStateException("Unexpected exception", e) @@ -121,15 +135,16 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea ) } - override fun deleteKey(alias: String) = storageEngine.delete(PREFIX + alias) + override suspend fun deleteKey(alias: String) { + storageTable.delete(alias) + } private data class KeyData( val keyPurposes: Set, val privateKey: EcPrivateKey, ) - private fun loadKey( - prefix: String, + private suspend fun loadKey( alias: String, keyUnlockData: KeyUnlockData? ): KeyData { @@ -138,9 +153,9 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea val unlockData = keyUnlockData as SoftwareKeyUnlockData passphrase = unlockData.passphrase } - val data = storageEngine[prefix + alias] + val data = storageTable.get(alias) ?: throw IllegalArgumentException("No key with given alias") - val map = Cbor.decode(data) + val map = Cbor.decode(data.toByteArray()) val keyPurposes = map["keyPurposes"].asNumber.keyPurposeSet val passphraseRequired = map["passphraseRequired"].asBoolean val privateKeyCoseKey = if (passphraseRequired) { @@ -171,34 +186,34 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea * @return a [PrivateKey]. * @throws KeyLockedException */ - fun getPrivateKey( + suspend fun getPrivateKey( alias: String, keyUnlockData: KeyUnlockData? - ): EcPrivateKey = loadKey(PREFIX, alias, keyUnlockData).privateKey + ): EcPrivateKey = loadKey(alias, keyUnlockData).privateKey - override fun sign( + override suspend fun sign( alias: String, signatureAlgorithm: Algorithm, dataToSign: ByteArray, keyUnlockData: KeyUnlockData? - ): EcSignature = loadKey(PREFIX, alias, keyUnlockData).run { + ): EcSignature = loadKey(alias, keyUnlockData).run { require(keyPurposes.contains(KeyPurpose.SIGN)) { "Key does not have purpose SIGN" } Crypto.sign(privateKey, signatureAlgorithm, dataToSign) } - override fun keyAgreement( + override suspend fun keyAgreement( alias: String, otherKey: EcPublicKey, keyUnlockData: KeyUnlockData? - ): ByteArray = loadKey(PREFIX, alias, keyUnlockData).run { + ): ByteArray = loadKey(alias, keyUnlockData).run { require(keyPurposes.contains(KeyPurpose.AGREE_KEY)) { "Key does not have purpose AGREE_KEY" } Crypto.keyAgreement(privateKey, otherKey) } - override fun getKeyInfo(alias: String): SoftwareKeyInfo { - val data = storageEngine[PREFIX + alias] - ?: throw IllegalArgumentException("No key with given alias") - val map = Cbor.decode(data) + override suspend fun getKeyInfo(alias: String): SoftwareKeyInfo { + val data = storageTable.get(alias) + ?: throw IllegalArgumentException("No key with the given alias '$alias'") + val map = Cbor.decode(data.toByteArray()) val keyPurposes = map["keyPurposes"].asNumber.keyPurposeSet val passphraseRequired = map["passphraseRequired"].asBoolean val publicKey = map["publicKey"].asCoseKey.ecPublicKey @@ -206,6 +221,7 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea PassphraseConstraints.fromDataItem(it) } return SoftwareKeyInfo( + alias, publicKey, KeyAttestation(publicKey, null), keyPurposes, @@ -214,7 +230,7 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea ) } - override fun getKeyInvalidated(alias: String): Boolean { + override suspend fun getKeyInvalidated(alias: String): Boolean { // Software keys are never invalidated. return false } @@ -222,7 +238,21 @@ class SoftwareSecureArea(private val storageEngine: StorageEngine) : SecureArea companion object { private const val TAG = "SoftwareSecureArea" - // Prefix for storage items. - private const val PREFIX = "IC_SoftwareSecureArea_key_" + const val IDENTIFIER = "SoftwareSecureArea" + + /** + * Creates an instance of SoftwareSecureArea. + * + * @param storage the storage engine to use for storing key material. + */ + suspend fun create(storage: Storage): SoftwareSecureArea { + return SoftwareSecureArea(storage.getTable(tableSpec)) + } + + private val tableSpec = StorageTableSpec( + name = "SoftwareSecureArea", + supportPartitions = false, + supportExpiration = false + ) } } diff --git a/identity/src/commonMain/kotlin/com/android/identity/storage/base/BaseStorage.kt b/identity/src/commonMain/kotlin/com/android/identity/storage/base/BaseStorage.kt index f553b7ee1..4d2642877 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/storage/base/BaseStorage.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/storage/base/BaseStorage.kt @@ -56,6 +56,37 @@ abstract class BaseStorage(val clock: Clock): Storage { } } + /** + * API for the derived classes to be able to iterate through all the tables in the storage. + * This includes a (hidden) schema table defined in [SchemaTableSpec]. + */ + protected suspend fun enumerateTables(): List { + return lock.withLock { + ensureTablesLoaded() + tableMap.values.map { it.table } + } + } + + /** + * API for the derived classes to be able to initialize newly created [BaseStorage] with + * the given list of tables. This must includes a (hidden) schema table defined in + * [SchemaTableSpec]. + */ + protected fun initTables(tables: List) { + if (tableMap.isNotEmpty()) { + throw IllegalStateException("Not an empty Storage") + } + for (table in tables) { + if (table.spec.name == SchemaTableSpec.name) { + schemaTable = table + } + tableMap[table.spec.name.lowercase()] = TableEntry(table) + } + if (schemaTable == null && tables.isNotEmpty()) { + throw IllegalArgumentException("Schema table missing") + } + } + override suspend fun purgeExpired() { val tablesToPurge = lock.withLock { ensureTablesLoaded() @@ -71,6 +102,7 @@ abstract class BaseStorage(val clock: Clock): Storage { if (schemaTable == null) { val schemaTable = createTable(SchemaTableSpec) this.schemaTable = schemaTable + tableMap[SchemaTableSpec.name.lowercase()] = TableEntry(schemaTable) tableMap.putAll(schemaTable.enumerate().map { name -> val storedSpec = StorageTableSpec.decodeByteString(schemaTable.get(name)!!) check(storedSpec.name == name) diff --git a/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorage.kt b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorage.kt index 90941951f..48a87e266 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorage.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorage.kt @@ -1,13 +1,40 @@ package com.android.identity.storage.ephemeral +import com.android.identity.cbor.Bstr import com.android.identity.storage.base.BaseStorage import com.android.identity.storage.base.BaseStorageTable import com.android.identity.storage.StorageTableSpec import kotlinx.datetime.Clock +import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.ByteStringBuilder class EphemeralStorage(clock: Clock = Clock.System) : BaseStorage(clock) { override suspend fun createTable(tableSpec: StorageTableSpec): BaseStorageTable { val clockToUse = if (tableSpec.supportExpiration) clock else StoppedClock return EphemeralStorageTable(tableSpec, clockToUse) } + + suspend fun serialize(): ByteString { + val out = ByteStringBuilder() + for (table in enumerateTables()) { + (table as EphemeralStorageTable).serialize(out) + } + return out.toByteString() + } + + companion object { + fun deserialize(data: ByteString, clock: Clock = Clock.System): EphemeralStorage { + var offset = 0 + val bytes = data.toByteArray() + val tables = mutableListOf() + while (offset < bytes.size) { + val (newOffset, table) = EphemeralStorageTable.deserialize(clock, bytes, offset) + offset = newOffset + tables.add(table) + } + val storage = EphemeralStorage(clock) + storage.initTables(tables) + return storage + } + } } \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageItem.kt b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageItem.kt new file mode 100644 index 000000000..eb8e27ec6 --- /dev/null +++ b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageItem.kt @@ -0,0 +1,31 @@ +package com.android.identity.storage.ephemeral + +import com.android.identity.cbor.annotation.CborSerializable +import kotlinx.datetime.Instant +import kotlinx.io.bytestring.ByteString + +// TODO: make this class internal, needs a fix for the annotation processor +@CborSerializable +class EphemeralStorageItem( + val partitionId: String?, + val key: String, + var value: ByteString = EphemeralStorageTable.EMPTY, + var expiration: Instant = Instant.DISTANT_FUTURE +): Comparable { + override fun compareTo(other: EphemeralStorageItem): Int { + val c = if (partitionId == null) { + if (other.partitionId == null) 0 else -1 + } else if (other.partitionId == null) { + 1 + } else { + partitionId.compareTo(other.partitionId) + } + return if (c != 0) c else key.compareTo(other.key) + } + + fun expired(now: Instant): Boolean { + return expiration < now + } + + companion object +} \ No newline at end of file diff --git a/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageTable.kt b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageTable.kt index e239da31e..7f76ff88d 100644 --- a/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageTable.kt +++ b/identity/src/commonMain/kotlin/com/android/identity/storage/ephemeral/EphemeralStorageTable.kt @@ -1,5 +1,9 @@ package com.android.identity.storage.ephemeral +import com.android.identity.cbor.Bstr +import com.android.identity.cbor.Cbor +import com.android.identity.cbor.CborArray +import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.storage.KeyExistsStorageException import com.android.identity.storage.NoRecordStorageException import com.android.identity.storage.base.BaseStorageTable @@ -10,6 +14,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.io.bytestring.ByteString +import kotlinx.io.bytestring.ByteStringBuilder import kotlin.math.abs import kotlin.random.Random @@ -18,13 +23,13 @@ internal class EphemeralStorageTable( private val clock: Clock ): BaseStorageTable(spec) { private val lock = Mutex() - private var storedData = mutableListOf() + private var storedData = mutableListOf() private var earliestExpiration: Instant = Instant.DISTANT_FUTURE override suspend fun get(key: String, partitionId: String?): ByteString? { checkPartition(partitionId) return lock.withLock { - val index = storedData.binarySearch(Item(partitionId, key)) + val index = storedData.binarySearch(EphemeralStorageItem(partitionId, key)) if (index < 0) { null } else { @@ -51,10 +56,10 @@ internal class EphemeralStorageTable( if (keyToUse == null) { do { keyToUse = Random.Default.nextBytes(9).toBase64Url() - index = storedData.binarySearch(Item(partitionId, keyToUse)) + index = storedData.binarySearch(EphemeralStorageItem(partitionId, keyToUse)) } while (index >= 0) } else { - index = storedData.binarySearch(Item(partitionId, keyToUse)) + index = storedData.binarySearch(EphemeralStorageItem(partitionId, keyToUse)) if (index >= 0) { val item = storedData[index] if (item.expired(clock.now())) { @@ -71,7 +76,7 @@ internal class EphemeralStorageTable( } check(index < 0) updateEarliestExpiration(expiration) - storedData.add(-index - 1, Item(partitionId, keyToUse!!, data, expiration)) + storedData.add(-index - 1, EphemeralStorageItem(partitionId, keyToUse!!, data, expiration)) keyToUse } } @@ -87,7 +92,7 @@ internal class EphemeralStorageTable( checkExpiration(expiration) } lock.withLock { - val index = storedData.binarySearch(Item(partitionId, key)) + val index = storedData.binarySearch(EphemeralStorageItem(partitionId, key)) if (index < 0) { throw NoRecordStorageException( "No record with ${recordDescription(key, partitionId)}") @@ -108,7 +113,7 @@ internal class EphemeralStorageTable( override suspend fun delete(key: String, partitionId: String?): Boolean { checkPartition(partitionId) return lock.withLock { - val index = storedData.binarySearch(Item(partitionId, key)) + val index = storedData.binarySearch(EphemeralStorageItem(partitionId, key)) if (index < 0 || storedData[index].expired(clock.now())) { false } else { @@ -136,10 +141,10 @@ internal class EphemeralStorageTable( } return lock.withLock { var index = if (afterKey == null) { - val spot = storedData.binarySearch(Item(partitionId, "")) + val spot = storedData.binarySearch(EphemeralStorageItem(partitionId, "")) if (spot > 0) spot else -(spot + 1) } else { - abs(storedData.binarySearch(Item(partitionId, afterKey)) + 1) + abs(storedData.binarySearch(EphemeralStorageItem(partitionId, afterKey)) + 1) } val now = clock.now() val keyList = mutableListOf() @@ -171,7 +176,7 @@ internal class EphemeralStorageTable( val now = clock.now() if (earliestExpiration < now) { earliestExpiration = Instant.DISTANT_FUTURE - val unexpired = mutableListOf() + val unexpired = mutableListOf() for (item in storedData) { if (!item.expired(now)) { updateEarliestExpiration(item.expiration) @@ -183,29 +188,30 @@ internal class EphemeralStorageTable( } } - private class Item( - val partitionId: String?, - val key: String, - var value: ByteString = EMPTY, - var expiration: Instant = Instant.DISTANT_FUTURE - ): Comparable { - override fun compareTo(other: Item): Int { - val c = if (partitionId == null) { - if (other.partitionId == null) 0 else -1 - } else if (other.partitionId == null) { - 1 - } else { - partitionId.compareTo(other.partitionId) - } - return if (c != 0) c else key.compareTo(other.key) - } - - fun expired(now: Instant): Boolean { - return expiration < now + internal suspend fun serialize(out: ByteStringBuilder) { + Bstr(spec.encodeToByteString().toByteArray()).encode(out) + val tableData = lock.withLock { + storedData.map { item -> item.toDataItem() } } + CborArray(tableData.toMutableList()).encode(out) } companion object { val EMPTY = ByteString() + + internal fun deserialize(clock: Clock, input: ByteArray, offset: Int): Pair { + val (offset1, specData) = Cbor.decode(input, offset) + val spec = StorageTableSpec.decodeByteString(ByteString(specData.asBstr)) + val (offset2, tableData) = Cbor.decode(input, offset1) + val table = EphemeralStorageTable(spec, clock) + for (itemData in tableData.asArray) { + val item = EphemeralStorageItem.fromDataItem(itemData) + if (table.earliestExpiration > item.expiration) { + table.earliestExpiration = item.expiration + } + table.storedData.add(item) + } + return Pair(offset2, table) + } } } \ No newline at end of file diff --git a/identity/src/commonTest/kotlin/com/android/identity/document/DocumentStoreTest.kt b/identity/src/commonTest/kotlin/com/android/identity/document/DocumentStoreTest.kt index 4d82e668c..2ad4ae58a 100644 --- a/identity/src/commonTest/kotlin/com/android/identity/document/DocumentStoreTest.kt +++ b/identity/src/commonTest/kotlin/com/android/identity/document/DocumentStoreTest.kt @@ -19,17 +19,19 @@ import com.android.identity.credential.Credential import com.android.identity.credential.CredentialFactory import com.android.identity.credential.SecureAreaBoundCredential import com.android.identity.crypto.EcCurve +import com.android.identity.document.DocumentStore.EventType import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.KeyPurpose -import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.ephemeral.EphemeralStorage import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant; import kotlin.test.BeforeTest @@ -44,8 +46,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class DocumentStoreTest { - private lateinit var storageEngine: StorageEngine - private lateinit var secureArea: SecureArea + private lateinit var storage: Storage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var credentialFactory: CredentialFactory @@ -54,27 +55,37 @@ class DocumentStoreTest { @BeforeTest fun setup() { - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) + storage = EphemeralStorage() + secureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation( SecureAreaBoundCredential::class - ) { document, dataItem -> SecureAreaBoundCredential(document, dataItem) } + ) { document, dataItem -> + SecureAreaBoundCredential(document).apply { deserialize(dataItem) } + } credentialFactory.addCredentialImplementation( Credential::class - ) { document, dataItem -> Credential(document, dataItem) } + ) { document, dataItem -> + TestCredential(document).apply { deserialize(dataItem) } + } } + private fun runDocumentTest(testBody: suspend TestScope.(docStore: DocumentStore) -> Unit) { + runTest { + val documentStore = DocumentStore( + storage, + secureAreaRepository, + credentialFactory, + backgroundScope + ) + testBody(documentStore) + } + } + @Test - fun testListDocuments() { - storageEngine.deleteAll() - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testListDocuments() = runDocumentTest { documentStore -> assertEquals(0, documentStore.listDocuments().size.toLong()) for (n in 0..9) { documentStore.addDocument(documentStore.createDocument("testDoc$n")) @@ -95,12 +106,13 @@ class DocumentStoreTest { @Test fun testEventFlow() = runTest { val documentStore = DocumentStore( - storageEngine, + storage, secureAreaRepository, - credentialFactory + credentialFactory, + backgroundScope ) - val events = mutableListOf>() + val events = mutableListOf>() backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { documentStore.eventFlow.toList(events) } @@ -108,34 +120,43 @@ class DocumentStoreTest { val doc0 = documentStore.createDocument("doc0") doc0.applicationData.setString("foo", "should not be notified") documentStore.addDocument(doc0) - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_ADDED, doc0), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_ADDED, doc0), events.last()) val doc1 = documentStore.createDocument("doc1") val doc2 = documentStore.createDocument("doc2") documentStore.addDocument(doc1) - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_ADDED, doc1), events.last()) + Pair(doc1, doc2) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_ADDED, doc1), events.last()) + documentStore.addDocument(doc2) - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_ADDED, doc2), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_ADDED, doc2), events.last()) + doc2.applicationData.setString("foo", "bar") - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_UPDATED, doc2), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_UPDATED, doc2), events.last()) + doc1.applicationData.setString("foo", "bar") - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_UPDATED, doc1), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_UPDATED, doc1), events.last()) + documentStore.deleteDocument("doc0") - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_DELETED, doc0), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_DELETED, doc0), events.last()) + documentStore.deleteDocument("doc2") - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_DELETED, doc2), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_DELETED, doc2), events.last()) + documentStore.deleteDocument("doc1") - assertEquals(Pair(DocumentStore.EventType.DOCUMENT_DELETED, doc1), events.last()) + runCurrent() + assertEquals(Pair(EventType.DOCUMENT_DELETED, doc1), events.last()) } @Test - fun testCreationDeletion() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) - + fun testCreationDeletion() = runDocumentTest { documentStore -> val document = documentStore.createDocument( "testDocument" ) @@ -144,7 +165,7 @@ class DocumentStoreTest { val document2 = documentStore.lookupDocument("testDocument") assertNotNull(document2) - assertEquals("testDocument", document2!!.name) + assertEquals("testDocument", document2.name) assertNull(documentStore.lookupDocument("nonExistingDocument")) @@ -156,12 +177,7 @@ class DocumentStoreTest { * relies on Document.equals() not being overridden. */ @Test - fun testCaching() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testCaching() = runDocumentTest { documentStore -> val a = documentStore.createDocument("a") documentStore.addDocument(a) val b = documentStore.createDocument("b") @@ -181,12 +197,7 @@ class DocumentStoreTest { } @Test - fun testNameSpacedData() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testNameSpacedData() = runDocumentTest { documentStore -> val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) val nameSpacedData = NameSpacedData.Builder() @@ -210,12 +221,7 @@ class DocumentStoreTest { } @Test - fun testCredentialUsage() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testCredentialUsage() = runDocumentTest { documentStore -> val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) val timeBeforeValidity = Instant.fromEpochMilliseconds(40) @@ -234,7 +240,7 @@ class DocumentStoreTest { // Create ten credentials... for (n in 0..9) { - val credential = Credential( + val credential = TestCredential( document, null, CREDENTIAL_DOMAIN @@ -311,6 +317,8 @@ class DocumentStoreTest { assertEquals(2, credential.usageCount.toLong()) } + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! + // Create and certify five replacements n = 0 while (n < 5) { @@ -318,9 +326,8 @@ class DocumentStoreTest { document, null, CREDENTIAL_DOMAIN, - secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), - ) + secureArea + ).generateKey(CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256)) n++ } assertEquals(10, document.certifiedCredentials.size.toLong()) @@ -370,21 +377,26 @@ class DocumentStoreTest { } } + @OptIn(ExperimentalCoroutinesApi::class) @Test - fun testCredentialPersistence() { - var n: Int - val timeValidityBegin = Instant.fromEpochMilliseconds(50) - val timeValidityEnd = Instant.fromEpochMilliseconds(150) + fun testCredentialPersistence() = runTest { val documentStore = DocumentStore( - storageEngine, + storage, secureAreaRepository, - credentialFactory + credentialFactory, + backgroundScope ) + + var n: Int + val timeValidityBegin = Instant.fromEpochMilliseconds(50) + val timeValidityEnd = Instant.fromEpochMilliseconds(150) val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) assertEquals(0, document.certifiedCredentials.size.toLong()) assertEquals(0, document.pendingCredentials.size.toLong()) + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! + // Create ten pending credentials and certify four of them n = 0 while (n < 4) { @@ -393,7 +405,8 @@ class DocumentStoreTest { null, CREDENTIAL_DOMAIN, secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + ).generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) n++ } @@ -422,26 +435,32 @@ class DocumentStoreTest { document, null, CREDENTIAL_DOMAIN, - secureArea, + secureArea + ).generateKey( CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) n++ } assertEquals(4, document.certifiedCredentials.size.toLong()) assertEquals(6, document.pendingCredentials.size.toLong()) + val pending = document.pendingCredentials + val certified = document.certifiedCredentials + + runCurrent() val documentStore2 = DocumentStore( - storageEngine, + storage, secureAreaRepository, - credentialFactory + credentialFactory, ) + val document2 = documentStore2.lookupDocument("testDocument") assertNotNull(document2) - assertEquals(4, document2!!.certifiedCredentials.size.toLong()) + assertEquals(4, document2.certifiedCredentials.size.toLong()) assertEquals(6, document2.pendingCredentials.size.toLong()) // Now check that what we loaded matches what we created in-memory just above. We // use the fact that the order of the credentials are preserved across save/load. - val it1 = document.certifiedCredentials.iterator() + val it1 = certified.iterator() val it2 = document2.certifiedCredentials.iterator() n = 0 while (n < 4) { @@ -453,10 +472,10 @@ class DocumentStoreTest { assertEquals(doc1.validUntil, doc2.validUntil) assertEquals(doc1.usageCount.toLong(), doc2.usageCount.toLong()) assertContentEquals(doc1.issuerProvidedData, doc2.issuerProvidedData) - assertEquals(doc1.attestation, doc2.attestation) + assertEquals(doc1.getAttestation(), doc2.getAttestation()) n++ } - val itp1 = document.pendingCredentials.iterator() + val itp1 = pending.iterator() val itp2 = document2.pendingCredentials.iterator() n = 0 while (n < 6) { @@ -464,18 +483,13 @@ class DocumentStoreTest { val doc2 = itp2.next() as SecureAreaBoundCredential assertEquals(doc1.identifier, doc2.identifier) assertEquals(doc1.alias, doc2.alias) - assertEquals(doc1.attestation, doc2.attestation) + assertEquals(doc1.getAttestation(), doc2.getAttestation()) n++ } } @Test - fun testCredentialValidity() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testCredentialValidity() = runDocumentTest { documentStore -> val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) @@ -492,6 +506,8 @@ class DocumentStoreTest { val timeOfUseAfterBirthday = Instant.fromEpochMilliseconds(120) val timeValidityEnd = Instant.fromEpochMilliseconds(150) + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! + // Create and certify ten credentials. Put age_in_years as the issuer provided data so we can // check it below. var n = 0 @@ -500,7 +516,9 @@ class DocumentStoreTest { document, null, CREDENTIAL_DOMAIN, - secureArea, + secureArea + ) + pendingCredential.generateKey( CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), ) n++ @@ -557,12 +575,7 @@ class DocumentStoreTest { } @Test - fun testApplicationData() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testApplicationData() = runDocumentTest { documentStore -> val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) val appData = document.applicationData @@ -605,21 +618,19 @@ class DocumentStoreTest { } @Test - fun testCredentialApplicationData() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testCredentialApplicationData() = runDocumentTest { documentStore -> var document: Document? = documentStore.createDocument("testDocument") documentStore.addDocument(document!!) + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! for (n in 0..9) { val pendingCredential = SecureAreaBoundCredential( document, null, CREDENTIAL_DOMAIN, secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + ) + pendingCredential.generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) val value = "bar$n" val pendingAppData = pendingCredential.applicationData @@ -681,23 +692,21 @@ class DocumentStoreTest { } @Test - fun testCredentialReplacement() { - val documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) + fun testCredentialReplacement() = runDocumentTest { documentStore -> val document = documentStore.createDocument("testDocument") documentStore.addDocument(document) assertEquals(0, document.certifiedCredentials.size.toLong()) assertEquals(0, document.pendingCredentials.size.toLong()) + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! for (n in 0..9) { val pendingCredential = SecureAreaBoundCredential( document, null, CREDENTIAL_DOMAIN, secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + ) + pendingCredential.generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) pendingCredential.certify( byteArrayOf(0, n.toByte()), @@ -716,7 +725,9 @@ class DocumentStoreTest { credToReplace, CREDENTIAL_DOMAIN, secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + ) + pendingCredential.generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) // ... it's not replaced until certify() is called assertEquals(1, document.pendingCredentials.size.toLong()) @@ -757,8 +768,10 @@ class DocumentStoreTest { document, toBeReplaced as SecureAreaBoundCredential, CREDENTIAL_DOMAIN, - secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + secureArea + ) + replacement.generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) assertEquals(toBeReplaced, replacement.replacementFor) assertEquals(replacement, toBeReplaced.replacement) @@ -772,11 +785,22 @@ class DocumentStoreTest { toBeReplaced, CREDENTIAL_DOMAIN, secureArea, - CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256), + ) + replacement.generateKey( + CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) ) assertEquals(toBeReplaced, replacement.replacementFor) assertEquals(replacement, toBeReplaced.replacement) toBeReplaced.delete() assertNull(replacement.replacementFor) } + + class TestCredential: Credential { + constructor(document: Document, asReplacementFor: Credential?, domain: String) + : super(document, asReplacementFor, domain) { + addToDocument() + } + + constructor(document: Document) : super(document) + } } diff --git a/identity/src/commonTest/kotlin/com/android/identity/document/DocumentUtilTest.kt b/identity/src/commonTest/kotlin/com/android/identity/document/DocumentUtilTest.kt index 14589b58d..5c4041742 100644 --- a/identity/src/commonTest/kotlin/com/android/identity/document/DocumentUtilTest.kt +++ b/identity/src/commonTest/kotlin/com/android/identity/document/DocumentUtilTest.kt @@ -23,8 +23,9 @@ import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant; import kotlin.test.BeforeTest import kotlin.test.Test @@ -33,30 +34,31 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class DocumentUtilTest { - private lateinit var storageEngine: StorageEngine - private lateinit var secureArea: SecureArea + private lateinit var storage: Storage private lateinit var secureAreaRepository: SecureAreaRepository private lateinit var credentialFactory: CredentialFactory @BeforeTest fun setup() { - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) + storage = EphemeralStorage() + secureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation( SecureAreaBoundCredential::class - ) { document, dataItem -> SecureAreaBoundCredential(document, dataItem) } + ) { document, dataItem -> SecureAreaBoundCredential(document).apply { deserialize(dataItem) } } } @Test - fun managedCredentialHelper() { + fun managedCredentialHelper() = runTest { val documentStore = DocumentStore( - storageEngine, + storage, secureAreaRepository, credentialFactory ) + val secureArea: SecureArea = + secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! val document = documentStore.createDocument( "testDocument" ) @@ -74,13 +76,16 @@ class DocumentUtilTest { numCredsCreated = DocumentUtil.managedCredentialHelper( document, managedCredDomain, - createCredential = {credentialToReplace -> SecureAreaBoundCredential( - document, - credentialToReplace, - managedCredDomain, - secureArea, - authKeySettings - )}, + createCredential = { credentialToReplace -> + val credential = SecureAreaBoundCredential( + document, + credentialToReplace, + managedCredDomain, + secureArea, + ) + credential.generateKey(authKeySettings) + credential + }, Instant.fromEpochMilliseconds(100), numCreds, maxUsesPerCred, @@ -109,13 +114,16 @@ class DocumentUtilTest { numCredsCreated = DocumentUtil.managedCredentialHelper( document, managedCredDomain, - createCredential = {credentialToReplace -> SecureAreaBoundCredential( - document, - credentialToReplace, - managedCredDomain, - secureArea, - authKeySettings - )}, + createCredential = { credentialToReplace -> + val credential = SecureAreaBoundCredential( + document, + credentialToReplace, + managedCredDomain, + secureArea, + ) + credential.generateKey(authKeySettings) + credential + }, Instant.fromEpochMilliseconds(100), numCreds, maxUsesPerCred, @@ -134,13 +142,16 @@ class DocumentUtilTest { numCredsCreated = DocumentUtil.managedCredentialHelper( document, managedCredDomain, - createCredential = {credentialToReplace -> SecureAreaBoundCredential( - document, - credentialToReplace, - managedCredDomain, - secureArea, - authKeySettings - )}, + createCredential = { credentialToReplace -> + val credential = SecureAreaBoundCredential( + document, + credentialToReplace, + managedCredDomain, + secureArea + ) + credential.generateKey(authKeySettings) + credential + }, Instant.fromEpochMilliseconds(100), numCreds, maxUsesPerCred, @@ -162,13 +173,16 @@ class DocumentUtilTest { numCredsCreated = DocumentUtil.managedCredentialHelper( document, managedCredDomain, - createCredential = {credentialToReplace -> SecureAreaBoundCredential( - document, - credentialToReplace, - managedCredDomain, - secureArea, - authKeySettings - )}, + createCredential = { credentialToReplace -> + val credential = SecureAreaBoundCredential( + document, + credentialToReplace, + managedCredDomain, + secureArea, + ) + credential.generateKey(authKeySettings) + credential + }, Instant.fromEpochMilliseconds(100), numCreds, maxUsesPerCred, @@ -214,13 +228,16 @@ class DocumentUtilTest { numCredsCreated = DocumentUtil.managedCredentialHelper( document, managedCredDomain, - createCredential = {credentialToReplace -> SecureAreaBoundCredential( - document, - credentialToReplace, - managedCredDomain, - secureArea, - authKeySettings - )}, + createCredential = { credentialToReplace -> + val credential = SecureAreaBoundCredential( + document, + credentialToReplace, + managedCredDomain, + secureArea + ) + credential.generateKey(authKeySettings) + credential + }, Instant.fromEpochMilliseconds(195), numCreds, maxUsesPerCred, diff --git a/identity/src/commonTest/kotlin/com/android/identity/securearea/SoftwareSecureAreaTest.kt b/identity/src/commonTest/kotlin/com/android/identity/securearea/SoftwareSecureAreaTest.kt index 323337509..5f7628e65 100644 --- a/identity/src/commonTest/kotlin/com/android/identity/securearea/SoftwareSecureAreaTest.kt +++ b/identity/src/commonTest/kotlin/com/android/identity/securearea/SoftwareSecureAreaTest.kt @@ -21,7 +21,8 @@ import com.android.identity.crypto.EcCurve import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareKeyUnlockData import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertContentEquals import kotlin.test.assertEquals @@ -34,9 +35,9 @@ import kotlin.test.fail class SoftwareSecureAreaTest { @Test - fun testEcKeyDeletion() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeyDeletion() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) // First create the key... ks.createKey( @@ -63,9 +64,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeySigning() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeySigning() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) ks.createKey( "testKey", CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) @@ -93,9 +94,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeyCreate() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeyCreate() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) ks.createKey( "testKey", CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) @@ -109,9 +110,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeyWithGenericCreateKeySettings() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeyWithGenericCreateKeySettings() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val challenge = byteArrayOf(1, 2, 3) ks.createKey("testKey", CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256)) val keyInfo = ks.getKeyInfo("testKey") @@ -127,9 +128,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeySigningWithKeyWithoutCorrectPurpose() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeySigningWithKeyWithoutCorrectPurpose() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) ks.createKey( "testKey", SoftwareCreateKeySettings.Builder() @@ -148,9 +149,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcdh() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcdh() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) ks.createKey( "testKey", @@ -185,9 +186,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcdhAndSigning() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcdhAndSigning() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) ks.createKey( "testKey", @@ -237,9 +238,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcdhWithoutCorrectPurpose() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcdhWithoutCorrectPurpose() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val otherKey = Crypto.createEcPrivateKey(EcCurve.P256) ks.createKey( "testKey", @@ -261,9 +262,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeySigningWithLockedKey() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeySigningWithLockedKey() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val passphrase = "verySekrit" val passphraseConstraints = PassphraseConstraints.PIN_SIX_DIGITS ks.createKey( @@ -329,9 +330,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeyCreationOverridesExistingAlias() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeyCreationOverridesExistingAlias() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) ks.createKey( "testKey", CreateKeySettings(setOf(KeyPurpose.SIGN), EcCurve.P256) @@ -366,9 +367,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeySigningAllCurves() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeySigningAllCurves() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val curvesSupportingSigning = setOf( EcCurve.P256, EcCurve.P384, @@ -433,9 +434,9 @@ class SoftwareSecureAreaTest { } @Test - fun testEcKeyEcdhAllCurves() { - val storage = EphemeralStorageEngine() - val ks = SoftwareSecureArea(storage) + fun testEcKeyEcdhAllCurves() = runTest { + val storage = EphemeralStorage() + val ks = SoftwareSecureArea.create(storage) val curvesSupportingKeyAgreement = arrayOf( EcCurve.P256, EcCurve.P384, diff --git a/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageEngineTest.kt b/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageEngineTest.kt new file mode 100644 index 000000000..c62151845 --- /dev/null +++ b/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageEngineTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.identity.storage + +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class EphemeralStorageEngineTest { + @Test + fun testStorageImplementation() { + val storage = EphemeralStorageEngine() + assertEquals(0, storage.enumerate().size.toLong()) + assertNull(storage["foo"]) + + val data = byteArrayOf(1, 2, 3) + storage.put("foo", data) + assertContentEquals(storage["foo"], data) + assertEquals(1, storage.enumerate().size.toLong()) + assertEquals("foo", storage.enumerate().iterator().next()) + assertNull(storage["bar"]) + + val data2 = byteArrayOf(4, 5, 6) + storage.put("bar", data2) + assertContentEquals(storage["bar"], data2) + assertEquals(2, storage.enumerate().size.toLong()) + storage.delete("foo") + assertNull(storage["foo"]) + assertNotNull(storage["bar"]) + assertEquals(1, storage.enumerate().size.toLong()) + storage.delete("bar") + assertNull(storage["bar"]) + assertEquals(0, storage.enumerate().size.toLong()) + } + + @Test + fun testPersistence() { + var storage: StorageEngine = EphemeralStorageEngine() + assertEquals(0, storage.enumerate().size.toLong()) + assertNull(storage["foo"]) + val data = byteArrayOf(1, 2, 3) + storage.put("foo", data) + assertContentEquals(storage["foo"], data) + + // Create a new StorageEngine instance and check that data is no longer there... + storage = EphemeralStorageEngine() + assertEquals(0, storage.enumerate().size.toLong()) + assertNull(storage["foo"]) + } +} diff --git a/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageTest.kt b/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageTest.kt index 1fb37f8aa..2b562e70d 100644 --- a/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageTest.kt +++ b/identity/src/commonTest/kotlin/com/android/identity/storage/EphemeralStorageTest.kt @@ -15,51 +15,60 @@ */ package com.android.identity.storage +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest +import kotlinx.io.bytestring.ByteString import kotlin.test.Test -import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull class EphemeralStorageTest { + companion object { + val tableSpec = StorageTableSpec("test", false, false) + } + @Test - fun testStorageImplementation() { - val storage = EphemeralStorageEngine() - assertEquals(0, storage.enumerate().size.toLong()) - assertNull(storage["foo"]) + fun testStorageImplementation() = runTest { + val storage = EphemeralStorage() + val table = storage.getTable(tableSpec) + assertEquals(0, table.enumerate().size) + assertNull(table.get("foo")) - val data = byteArrayOf(1, 2, 3) - storage.put("foo", data) - assertContentEquals(storage["foo"], data) - assertEquals(1, storage.enumerate().size.toLong()) - assertEquals("foo", storage.enumerate().iterator().next()) - assertNull(storage["bar"]) + val data = ByteString(byteArrayOf(1, 2, 3)) + table.insert("foo", data) + assertEquals(table.get("foo"), data) + assertEquals(1, table.enumerate().size.toLong()) + assertEquals("foo", table.enumerate().iterator().next()) + assertNull(table.get("bar")) - val data2 = byteArrayOf(4, 5, 6) - storage.put("bar", data2) - assertContentEquals(storage["bar"], data2) - assertEquals(2, storage.enumerate().size.toLong()) - storage.delete("foo") - assertNull(storage["foo"]) - assertNotNull(storage["bar"]) - assertEquals(1, storage.enumerate().size.toLong()) - storage.delete("bar") - assertNull(storage["bar"]) - assertEquals(0, storage.enumerate().size.toLong()) + val data2 = ByteString(byteArrayOf(4, 5, 6)) + table.insert("bar", data2) + assertEquals(table.get("bar"), data2) + assertEquals(2, table.enumerate().size.toLong()) + table.delete("foo") + assertNull(table.get("foo")) + assertNotNull(table.get("bar")) + assertEquals(1, table.enumerate().size.toLong()) + table.delete("bar") + assertNull(table.get("bar")) + assertEquals(0, table.enumerate().size.toLong()) } @Test - fun testPersistence() { - var storage: StorageEngine = EphemeralStorageEngine() - assertEquals(0, storage.enumerate().size.toLong()) - assertNull(storage["foo"]) - val data = byteArrayOf(1, 2, 3) - storage.put("foo", data) - assertContentEquals(storage["foo"], data) + fun testPersistence() = runTest { + var storage = EphemeralStorage() + var table = storage.getTable(tableSpec) + assertEquals(0, table.enumerate().size.toLong()) + assertNull(table.get("foo")) + val data = ByteString(byteArrayOf(1, 2, 3)) + table.insert("foo", data) + + storage = EphemeralStorage.deserialize(storage.serialize()) - // Create a new StorageEngine instance and check that data is no longer there... - storage = EphemeralStorageEngine() - assertEquals(0, storage.enumerate().size.toLong()) - assertNull(storage["foo"]) + table = storage.getTable(tableSpec) + assertEquals(1, table.enumerate().size.toLong()) + assertEquals("foo", table.enumerate().iterator().next()) + assertEquals(data, table.get("foo")) } } diff --git a/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveKeyInfo.kt b/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveKeyInfo.kt index abaf16a18..c47756556 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveKeyInfo.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveKeyInfo.kt @@ -9,6 +9,7 @@ import kotlinx.datetime.Instant * Secure Enclave specific class for information about a key. */ class SecureEnclaveKeyInfo internal constructor( + alias: String, publicKey: EcPublicKey, keyPurposes: Set, @@ -22,4 +23,4 @@ class SecureEnclaveKeyInfo internal constructor( */ val userAuthenticationTypes: Set -): KeyInfo(publicKey, keyPurposes, KeyAttestation(publicKey, null)) +): KeyInfo(alias, publicKey, keyPurposes, KeyAttestation(publicKey, null)) diff --git a/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveSecureArea.kt b/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveSecureArea.kt index f1113d10c..bbf00bd5f 100644 --- a/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveSecureArea.kt +++ b/identity/src/iosMain/kotlin/com/android/identity/securearea/SecureEnclaveSecureArea.kt @@ -7,8 +7,11 @@ import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve import com.android.identity.crypto.EcPublicKey import com.android.identity.crypto.EcSignature -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger +import kotlinx.io.bytestring.ByteString /** * An implementation of [SecureArea] using the Apple Secure Enclave. @@ -31,15 +34,27 @@ import com.android.identity.util.Logger * As the Secure Enclave does not current support key attestation, the base [KeyAttestation] * object is used. */ -class SecureEnclaveSecureArea( - private val storageEngine: StorageEngine +class SecureEnclaveSecureArea private constructor( + private val storageTable: StorageTable ): SecureArea { companion object { private val TAG = "SecureEnclaveSecureArea" - // Prefix used for storage items, the key alias follows. - private const val PREFIX = "IC_SecureEnclave_" + /** + * Creates an instance of [SecureEnclaveSecureArea]. + * + * @param storage the storage engine to use for storing key material. + */ + suspend fun create(storage: Storage): SecureEnclaveSecureArea { + return SecureEnclaveSecureArea(storage.getTable(tableSpec)) + } + + private val tableSpec = StorageTableSpec( + name = "SecureEnclaveSecureArea", + supportPartitions = false, + supportExpiration = false + ) } override val identifier: String @@ -48,7 +63,13 @@ class SecureEnclaveSecureArea( override val displayName: String get() = "Secure Enclave Secure Area" - override fun createKey(alias: String, createKeySettings: CreateKeySettings) { + override suspend fun createKey(alias: String?, createKeySettings: CreateKeySettings): KeyInfo { + if (alias != null) { + // If the key with the given alias exists, it is silently overwritten. + // TODO: review if this is the semantics we want + storageTable.delete(alias) + } + val settings = if (createKeySettings is SecureEnclaveCreateKeySettings) { createKeySettings } else { @@ -67,15 +88,16 @@ class SecureEnclaveSecureArea( accessControlCreateFlags ) Logger.d(TAG, "EC key with alias '$alias' created") - saveKey(alias, settings, keyBlob, pubKey) + val newAlias = insertKey(alias, settings, keyBlob, pubKey) + return getKeyInfo(newAlias) } - private fun saveKey( - alias: String, + private suspend fun insertKey( + alias: String?, settings: SecureEnclaveCreateKeySettings, keyBlob: ByteArray, publicKey: EcPublicKey, - ) { + ): String { val map = CborMap.builder() map.put("keyPurposes", KeyPurpose.encodeSet(settings.keyPurposes)) map.put("userAuthenticationRequired", settings.userAuthenticationRequired) @@ -84,13 +106,14 @@ class SecureEnclaveSecureArea( map.put("curve", settings.ecCurve.coseCurveIdentifier) map.put("publicKey", publicKey.toDataItem()) map.put("keyBlob", keyBlob) - storageEngine.put(PREFIX + alias, Cbor.encode(map.end().build())) + return storageTable.insert(alias, ByteString(Cbor.encode(map.end().build()))) } - private fun loadKey(alias: String): Pair { - val data = storageEngine[PREFIX + alias] ?: throw IllegalArgumentException("No key with given alias") + private suspend fun loadKey(alias: String): Pair { + val data = storageTable.get(alias) + ?: throw IllegalArgumentException("No key with given alias") - val map = Cbor.decode(data) + val map = Cbor.decode(data.toByteArray()) val keyPurposes = map["keyPurposes"].asNumber.keyPurposeSet val userAuthenticationRequired = map["userAuthenticationRequired"].asBoolean val userAuthenticationTypes = @@ -99,6 +122,7 @@ class SecureEnclaveSecureArea( val keyBlob = map["keyBlob"].asBstr val keyInfo = SecureEnclaveKeyInfo( + alias, publicKey, keyPurposes, userAuthenticationRequired, @@ -107,11 +131,11 @@ class SecureEnclaveSecureArea( return Pair(keyBlob, keyInfo) } - override fun deleteKey(alias: String) { - storageEngine.delete(PREFIX + alias) + override suspend fun deleteKey(alias: String) { + storageTable.delete(alias) } - override fun sign( + override suspend fun sign( alias: String, signatureAlgorithm: Algorithm, dataToSign: ByteArray, @@ -124,7 +148,7 @@ class SecureEnclaveSecureArea( return Crypto.secureEnclaveEcSign(keyBlob, dataToSign, keyUnlockData) } - override fun keyAgreement( + override suspend fun keyAgreement( alias: String, otherKey: EcPublicKey, keyUnlockData: KeyUnlockData? @@ -136,13 +160,12 @@ class SecureEnclaveSecureArea( return Crypto.secureEnclaveEcKeyAgreement(keyBlob, otherKey, keyUnlockData) } - override fun getKeyInfo(alias: String): KeyInfo { + override suspend fun getKeyInfo(alias: String): KeyInfo { val (_, keyInfo) = loadKey(alias) return keyInfo } - override fun getKeyInvalidated(alias: String): Boolean { + override suspend fun getKeyInvalidated(alias: String): Boolean { return false } - } \ No newline at end of file diff --git a/samples/age-verifier-mdl/src/main/java/com/android/identity/age_verifier_mdl/TransferHelper.kt b/samples/age-verifier-mdl/src/main/java/com/android/identity/age_verifier_mdl/TransferHelper.kt index 07df96877..413a40961 100644 --- a/samples/age-verifier-mdl/src/main/java/com/android/identity/age_verifier_mdl/TransferHelper.kt +++ b/samples/age-verifier-mdl/src/main/java/com/android/identity/age_verifier_mdl/TransferHelper.kt @@ -14,17 +14,16 @@ import com.android.identity.android.mdoc.transport.ConnectionMethodTcp import com.android.identity.android.mdoc.transport.ConnectionMethodUdp import com.android.identity.android.mdoc.transport.DataTransportOptions import com.android.identity.android.securearea.AndroidKeystoreSecureArea -import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.mdoc.connectionmethod.ConnectionMethod import com.android.identity.mdoc.connectionmethod.ConnectionMethodBle import com.android.identity.mdoc.connectionmethod.ConnectionMethodNfc import com.android.identity.mdoc.connectionmethod.ConnectionMethodWifiAware -import com.android.identity.storage.StorageEngine +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity.util.Logger import com.android.identity.util.UUID -import kotlinx.io.files.Path import java.io.File -import java.util.OptionalLong class TransferHelper private constructor( private var context: Context, @@ -58,8 +57,8 @@ class TransferHelper private constructor( } private var sharedPreferences: SharedPreferences - private var storageEngine: StorageEngine - var androidKeystoreSecureArea: AndroidKeystoreSecureArea + private var storage: Storage + var androidKeystoreSecureAreaProvider: SecureAreaProvider private var verificationHelper: VerificationHelper? = null var deviceResponseBytes: ByteArray? = null @@ -202,9 +201,11 @@ class TransferHelper private constructor( init { sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - val storageFile = Path(context.noBackupFilesDir.path, "identity.bin") - storageEngine = AndroidStorageEngine.Builder(context, storageFile).build() - androidKeystoreSecureArea = AndroidKeystoreSecureArea(context, storageEngine); + val storageFile = File(context.noBackupFilesDir.path, "identity.db") + storage = AndroidStorage(storageFile.absolutePath) + androidKeystoreSecureAreaProvider = SecureAreaProvider { + AndroidKeystoreSecureArea.create(context, storage) + } state.value = State.IDLE initializeVerificationHelper() diff --git a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/MainActivity.kt b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/MainActivity.kt index ebceb3dac..b7a373d9b 100644 --- a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/MainActivity.kt +++ b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/MainActivity.kt @@ -53,7 +53,9 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.android.identity.android.securearea.AndroidKeystoreCreateKeySettings +import com.android.identity.android.securearea.AndroidKeystoreSecureArea import com.android.identity.cbor.Bstr import com.android.identity.cbor.Cbor import com.android.identity.cbor.DataItem @@ -74,6 +76,7 @@ import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.util.MdocUtil import com.android.identity.preconsent_mdl.ui.theme.IdentityCredentialTheme import com.android.identity.util.Logger +import kotlinx.coroutines.launch import kotlinx.datetime.Clock import kotlinx.datetime.Instant import java.io.ByteArrayOutputStream @@ -95,7 +98,7 @@ class MainActivity : ComponentActivity() { private lateinit var transferHelper: TransferHelper - private fun provisionDocuments() { + private suspend fun provisionDocuments() { if (transferHelper.documentStore.lookupDocument(CREDENTIAL_ID) == null) { provisionDocument() } else { @@ -103,7 +106,7 @@ class MainActivity : ComponentActivity() { } } - private fun provisionDocument() { + private suspend fun provisionDocument() { val document = transferHelper.documentStore.createDocument(CREDENTIAL_ID) transferHelper.documentStore.addDocument(document) @@ -145,16 +148,17 @@ class MainActivity : ComponentActivity() { document, null, AUTH_KEY_DOMAIN, - transferHelper.androidKeystoreSecureArea, - AndroidKeystoreCreateKeySettings.Builder("".toByteArray()).build(), + transferHelper.secureAreaRepository.getImplementation(AndroidKeystoreSecureArea.IDENTIFIER)!!, MDL_DOCTYPE - ) + ).apply { + generateKey(AndroidKeystoreCreateKeySettings.Builder("".toByteArray()).build()) + } // Generate an MSO and issuer-signed data for this credentials. val msoGenerator = MobileSecurityObjectGenerator( "SHA-256", MDL_DOCTYPE, - pendingCredential.attestation.publicKey + pendingCredential.getAttestation().publicKey ) msoGenerator.setValidityInfo( timeSigned, @@ -268,7 +272,10 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) transferHelper = TransferHelper.getInstance(applicationContext) - provisionDocuments() + + lifecycleScope.launch { + provisionDocuments() + } val permissionsNeeded = appPermissions.filter { permission -> ContextCompat.checkSelfPermission( diff --git a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/PresentationActivity.kt b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/PresentationActivity.kt index 6333e5c5f..61d1e2320 100644 --- a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/PresentationActivity.kt +++ b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/PresentationActivity.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -54,6 +55,7 @@ import com.android.identity.crypto.Algorithm import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.util.Constants import com.android.identity.util.Logger +import kotlinx.coroutines.launch import kotlinx.datetime.Clock class PresentationActivity : ComponentActivity() { @@ -88,6 +90,7 @@ class PresentationActivity : ComponentActivity() { IdentityCredentialTheme { var stateDisplay = remember { mutableStateOf("Idle") } + val coroutineScope = rememberCoroutineScope() transferHelper.getState().observe(this as LifecycleOwner) { state -> when (state) { @@ -111,7 +114,9 @@ class PresentationActivity : ComponentActivity() { TransferHelper.State.REQUEST_AVAILABLE -> { stateDisplay.value = "Request Available" Logger.i(TAG, "State: Request Available") - processRequest() + coroutineScope.launch { + processRequest() + } } TransferHelper.State.RESPONSE_SENT -> { stateDisplay.value = "Response Sent" @@ -249,16 +254,18 @@ class PresentationActivity : ComponentActivity() { } - private fun processRequest() { + private suspend fun processRequest() { val request = DeviceRequestParser( transferHelper.getDeviceRequest(), transferHelper.getSessionTranscript() ).parse() val docRequest = request.docRequests[0] - val documentRequest = MdocUtil.generateDocumentRequest(docRequest!!) + val documentRequest = MdocUtil.generateDocumentRequest(docRequest) val now = Clock.System.now() + val document = transferHelper.documentStore.lookupDocument(MainActivity.CREDENTIAL_ID)!! - val credential = document.findCredential(MainActivity.AUTH_KEY_DOMAIN, now) as MdocCredential + val credential = + document.findCredential(MainActivity.AUTH_KEY_DOMAIN, now) as MdocCredential val staticAuthData = StaticAuthDataParser(credential.issuerProvidedData).parse() val mergedIssuerNamespaces = MdocUtil.mergeIssuerNamesSpaces( @@ -267,9 +274,14 @@ class PresentationActivity : ComponentActivity() { staticAuthData ) - val deviceResponseGenerator = DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK) + val deviceResponseGenerator = + DeviceResponseGenerator(Constants.DEVICE_RESPONSE_STATUS_OK) deviceResponseGenerator.addDocument( - DocumentGenerator(MainActivity.MDL_DOCTYPE, staticAuthData.issuerAuth, transferHelper.getSessionTranscript()) + DocumentGenerator( + MainActivity.MDL_DOCTYPE, + staticAuthData.issuerAuth, + transferHelper.getSessionTranscript() + ) .setIssuerNamespaces(mergedIssuerNamespaces) .setDeviceNamespacesSignature( NameSpacedData.Builder().build(), diff --git a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/TransferHelper.kt b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/TransferHelper.kt index 38c8616d9..2fa752b1b 100644 --- a/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/TransferHelper.kt +++ b/samples/preconsent-mdl/src/main/java/com/android/identity/preconsent_mdl/TransferHelper.kt @@ -26,7 +26,6 @@ import androidx.lifecycle.MutableLiveData import com.android.identity.android.mdoc.deviceretrieval.DeviceRetrievalHelper import com.android.identity.android.mdoc.transport.DataTransport import com.android.identity.android.securearea.AndroidKeystoreSecureArea -import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.credential.CredentialFactory import com.android.identity.document.DocumentStore import com.android.identity.mdoc.connectionmethod.ConnectionMethod @@ -35,12 +34,12 @@ import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.securearea.SecureAreaRepository -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity.util.Constants import com.android.identity.util.Logger -import java.io.File import kotlinx.datetime.Clock -import kotlinx.io.files.Path +import java.io.File class TransferHelper private constructor(private val context: Context) { @@ -72,12 +71,11 @@ class TransferHelper private constructor(private val context: Context) { } var secureAreaRepository: SecureAreaRepository - var androidKeystoreSecureArea: AndroidKeystoreSecureArea var documentStore: DocumentStore private var credentialFactory: CredentialFactory private var sharedPreferences: SharedPreferences - private var storageEngine: StorageEngine + private var storage: Storage private var deviceRetrievalHelper: DeviceRetrievalHelper? = null private var connectionMethod: ConnectionMethod? = null private var deviceRequest: ByteArray? = null @@ -100,17 +98,17 @@ class TransferHelper private constructor(private val context: Context) { } init { - val storagePath = Path(context.noBackupFilesDir.path, "identity.bin") + val storagePath = File(context.noBackupFilesDir.path, "identity.bin").absolutePath sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) - storageEngine = AndroidStorageEngine.Builder(context, storagePath).build() - secureAreaRepository = SecureAreaRepository(); - androidKeystoreSecureArea = AndroidKeystoreSecureArea(context, storageEngine); - secureAreaRepository.addImplementation(androidKeystoreSecureArea); + storage = AndroidStorage(storagePath) + secureAreaRepository = SecureAreaRepository.build { + add(AndroidKeystoreSecureArea.create(context, storage)) + } credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(MdocCredential::class) { - document, dataItem -> MdocCredential(document, dataItem) + document, dataItem -> MdocCredential(document).apply { deserialize(dataItem) } } - documentStore = DocumentStore(storageEngine, secureAreaRepository, credentialFactory) + documentStore = DocumentStore(storage, secureAreaRepository, credentialFactory) state.value = State.NOT_CONNECTED } diff --git a/samples/testapp/build.gradle.kts b/samples/testapp/build.gradle.kts index 30bdc201d..6bf1ce1a5 100644 --- a/samples/testapp/build.gradle.kts +++ b/samples/testapp/build.gradle.kts @@ -45,6 +45,8 @@ kotlin { val iosMain by getting { dependencies { implementation(libs.ktor.client.darwin) + implementation(libs.androidx.sqlite) + implementation(libs.androidx.sqlite.framework) } } diff --git a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/PlatformAndroid.kt b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/PlatformAndroid.kt index 598e2ce77..599092929 100644 --- a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/PlatformAndroid.kt +++ b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/PlatformAndroid.kt @@ -7,7 +7,11 @@ import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.SecureArea import com.android.identity.util.AndroidContexts +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import kotlinx.io.files.Path +import java.io.File import java.net.NetworkInterface actual val platform = Platform.ANDROID @@ -26,19 +30,22 @@ actual fun getLocalIpAddress(): String { throw IllegalStateException("Unable to determine address") } -private val androidKeystoreStorage: AndroidStorageEngine by lazy { - AndroidStorageEngine.Builder( - AndroidContexts.applicationContext, - Path(AndroidContexts.applicationContext.dataDir.path, "testapp-default.bin") - ).build() +private val androidStorage: AndroidStorage by lazy { + AndroidStorage( + File(AndroidContexts.applicationContext.dataDir.path, "storage.db").absolutePath + ) +} + +actual fun platformStorage(): Storage { + return androidStorage } -private val androidKeystoreSecureArea: AndroidKeystoreSecureArea by lazy { - AndroidKeystoreSecureArea(AndroidContexts.applicationContext, androidKeystoreStorage) +private val androidKeystoreSecureAreaProvider = SecureAreaProvider { + AndroidKeystoreSecureArea.create(AndroidContexts.applicationContext, androidStorage) } -actual fun platformSecureArea(): SecureArea { - return androidKeystoreSecureArea +actual fun platformSecureAreaProvider(): SecureAreaProvider { + return androidKeystoreSecureAreaProvider } actual fun platformKeySetting(clientId: String): CreateKeySettings { diff --git a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/AndroidKeystoreSecureAreaScreenAndroid.kt b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/AndroidKeystoreSecureAreaScreenAndroid.kt index 495bc3fce..d51ea8f8c 100644 --- a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/AndroidKeystoreSecureAreaScreenAndroid.kt +++ b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/AndroidKeystoreSecureAreaScreenAndroid.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -60,6 +61,7 @@ import com.android.identity.crypto.javaX509Certificate import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.util.AndroidContexts +import com.android.identity.testapp.platformSecureAreaProvider import com.android.identity.util.Logger import com.android.identity.util.toHex import kotlinx.coroutines.CoroutineScope @@ -71,17 +73,6 @@ import kotlin.time.Duration.Companion.days private val TAG = "AndroidKeystoreSecureAreaScreen" -private val androidKeystoreStorage: AndroidStorageEngine by lazy { - AndroidStorageEngine.Builder( - AndroidContexts.applicationContext, - Path(AndroidContexts.applicationContext.dataDir.path, "testdata.bin") - ).build() -} - -private val androidKeystoreSecureArea: AndroidKeystoreSecureArea by lazy { - AndroidKeystoreSecureArea(AndroidContexts.applicationContext, androidKeystoreStorage) -} - private val androidKeystoreCapabilities: AndroidKeystoreSecureArea.Capabilities by lazy { AndroidKeystoreSecureArea.Capabilities(AndroidContexts.applicationContext) } @@ -117,7 +108,9 @@ actual fun AndroidKeystoreSecureAreaScreen(showToast: (message: String) -> Unit) showCertificateDialog.value = null }) } - + + val coroutineScope = rememberCoroutineScope() + LazyColumn { item { @@ -135,10 +128,11 @@ actual fun AndroidKeystoreSecureAreaScreen(showToast: (message: String) -> Unit) item { TextButton(onClick = { - // TODO: Does a lot of I/O, cannot run on UI thread - val attestation = aksAttestation(false) - Logger.d(TAG, "attestation: " + attestation) - showCertificateDialog.value = attestation + coroutineScope.launch { + val attestation = aksAttestation(false) + Logger.d(TAG, "attestation: " + attestation) + showCertificateDialog.value = attestation + } }) { Text( @@ -150,10 +144,11 @@ actual fun AndroidKeystoreSecureAreaScreen(showToast: (message: String) -> Unit) item { TextButton(onClick = { - // TODO: Does a lot of I/O, cannot run on UI thread - val attestation = aksAttestation(true) - Logger.d(TAG, "attestation: " + attestation) - showCertificateDialog.value = attestation + coroutineScope.launch { + val attestation = aksAttestation(true) + Logger.d(TAG, "attestation: " + attestation) + showCertificateDialog.value = attestation + } }) { Text( @@ -202,17 +197,18 @@ actual fun AndroidKeystoreSecureAreaScreen(showToast: (message: String) -> Unit) val biometricConfirmationRequired = (authTimeout >= 0L) item { TextButton(onClick = { - // TODO: Does a lot of I/O, cannot run on UI thread - aksTest( - keyPurpose, - curve, - userAuthType != AUTH_NONE, - if (authTimeout < 0L) 0L else authTimeout, - userAuthType, - biometricConfirmationRequired, - strongBox, - showToast - ) + coroutineScope.launch { + aksTest( + keyPurpose, + curve, + userAuthType != AUTH_NONE, + if (authTimeout < 0L) 0L else authTimeout, + userAuthType, + biometricConfirmationRequired, + strongBox, + showToast + ) + } }) { Text( @@ -472,9 +468,10 @@ private fun getFeatureVersionKeystore(appContext: Context, useStrongbox: Boolean return 0 } -private fun aksAttestation(strongBox: Boolean): X509CertChain { +private suspend fun aksAttestation(strongBox: Boolean): X509CertChain { val now = Clock.System.now() val thirtyDaysFromNow = now + 30.days + val androidKeystoreSecureArea = platformSecureAreaProvider().get() as AndroidKeystoreSecureArea androidKeystoreSecureArea.createKey( "testKey", AndroidKeystoreCreateKeySettings.Builder("Challenge".toByteArray()) @@ -489,7 +486,7 @@ private fun aksAttestation(strongBox: Boolean): X509CertChain { return androidKeystoreSecureArea.getKeyInfo("testKey").attestation.certChain!! } -private fun aksTest( +private suspend fun aksTest( keyPurpose: KeyPurpose, curve: EcCurve, authRequired: Boolean, @@ -511,7 +508,7 @@ private fun aksTest( } } -private fun aksTestUnguarded( +private suspend fun aksTestUnguarded( keyPurpose: KeyPurpose, curve: EcCurve, authRequired: Boolean, @@ -521,6 +518,7 @@ private fun aksTestUnguarded( strongBox: Boolean, showToast: (message: String) -> Unit) { + val androidKeystoreSecureArea = platformSecureAreaProvider().get() as AndroidKeystoreSecureArea androidKeystoreSecureArea.createKey( "testKey", AndroidKeystoreCreateKeySettings.Builder("Challenge".toByteArray()) @@ -631,9 +629,9 @@ private fun doUserAuth( cryptoObject: BiometricPrompt.CryptoObject?, forceLskf: Boolean, biometricConfirmationRequired: Boolean, - onAuthSuccees: () -> Unit, - onAuthFailure: () -> Unit, - onDismissed: () -> Unit + onAuthSuccees: suspend () -> Unit, + onAuthFailure: suspend () -> Unit, + onDismissed: suspend () -> Unit ) { // Run this in a worker thread... CoroutineScope(Dispatchers.IO).launch { diff --git a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/CloudSecureAreaScreenAndroid.kt b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/CloudSecureAreaScreenAndroid.kt index cc9c8eccb..be67ae52a 100644 --- a/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/CloudSecureAreaScreenAndroid.kt +++ b/samples/testapp/src/androidMain/kotlin/com/android/identity/testapp/ui/CloudSecureAreaScreenAndroid.kt @@ -34,6 +34,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -67,12 +68,12 @@ import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.PassphraseConstraints import com.android.identity.storage.EphemeralStorageEngine import com.android.identity.util.AndroidContexts +import com.android.identity.storage.ephemeral.EphemeralStorage import com.android.identity.util.Logger import com.android.identity.util.toHex import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock import kotlin.time.Duration.Companion.days @@ -120,6 +121,8 @@ actual fun CloudSecureAreaScreen(showToast: (message: String) -> Unit) { val showConnectDialog = remember { mutableStateOf(false) } val showPassphraseDialog = remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() + if (showCertificateDialog.value != null) { ShowCertificateDialog(showCertificateDialog.value!!.certChain!!, onDismissRequest = { @@ -134,7 +137,7 @@ actual fun CloudSecureAreaScreen(showToast: (message: String) -> Unit) { }, onContinueButtonClicked = { passphraseEnteredByUser: String -> val configuration = showPassphraseDialog.value!! - CoroutineScope(Dispatchers.IO).launch { + coroutineScope.launch { csaTest( configuration.keyPurpose, configuration.curve, @@ -163,20 +166,17 @@ actual fun CloudSecureAreaScreen(showToast: (message: String) -> Unit) { onConnectButtonClicked = { url: String, walletPin: String -> sharedPreferences.edit().putString("csaUrl", url).apply() showConnectDialog.value = false - CoroutineScope(Dispatchers.IO).launch { - cloudSecureArea = CloudSecureArea( - AndroidContexts.applicationContext, - EphemeralStorageEngine(), + coroutineScope.launch { + cloudSecureArea = CloudSecureArea.create( + EphemeralStorage(), "CloudSecureArea", url ) try { - runBlocking { - cloudSecureArea!!.register( - walletPin, - PassphraseConstraints.PIN_SIX_DIGITS, - { true }) - } + cloudSecureArea!!.register( + walletPin, + PassphraseConstraints.PIN_SIX_DIGITS + ) { true } showToast("Registered with CSA") connectText = "Connected to ${cloudSecureArea!!.serverUrl}" @@ -195,13 +195,15 @@ actual fun CloudSecureAreaScreen(showToast: (message: String) -> Unit) { item { TextButton(onClick = { - if (cloudSecureArea != null) { - cloudSecureArea!!.unregister() - cloudSecureArea = null - connectText = "Click to connect to Cloud Secure Area" - connectColor = Color.Red - } else { - showConnectDialog.value = true + coroutineScope.launch { + if (cloudSecureArea != null) { + cloudSecureArea!!.unregister() + cloudSecureArea = null + connectText = "Click to connect to Cloud Secure Area" + connectColor = Color.Red + } else { + showConnectDialog.value = true + } } }) { @@ -623,7 +625,7 @@ private fun ShowPassphraseDialog( } } -private fun csaAttestation( +private suspend fun csaAttestation( showToast: (message: String) -> Unit ): KeyAttestation? { if (cloudSecureArea == null) { @@ -813,9 +815,9 @@ private suspend fun doUserAuth( cryptoObject: BiometricPrompt.CryptoObject?, forceLskf: Boolean, biometricConfirmationRequired: Boolean, - onAuthSuccees: () -> Unit, - onAuthFailure: () -> Unit, - onDismissed: () -> Unit + onAuthSuccees: suspend () -> Unit, + onAuthFailure: suspend () -> Unit, + onDismissed: suspend () -> Unit ) { val promptInfoBuilder = BiometricPrompt.PromptInfo.Builder() .setTitle("Authentication required") diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt index 4ab4e44f0..50a28d800 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/Platform.kt @@ -2,6 +2,8 @@ package com.android.identity.testapp import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.Storage enum class Platform(val displayName: String) { ANDROID("Android"), @@ -14,6 +16,8 @@ expect fun getLocalIpAddress(): String expect val platformIsEmulator: Boolean -expect fun platformSecureArea(): SecureArea +expect fun platformStorage(): Storage + +expect fun platformSecureAreaProvider(): SecureAreaProvider expect fun platformKeySetting(clientId: String): CreateKeySettings diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppPresentmentSource.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppPresentmentSource.kt index 8af3eac40..5889be844 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppPresentmentSource.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppPresentmentSource.kt @@ -35,7 +35,7 @@ class TestAppPresentmentSource( } } - override fun selectCredentialForPresentment( + override suspend fun selectCredentialForPresentment( request: Request, preSelectedDocument: Document? ): List { @@ -54,7 +54,7 @@ class TestAppPresentmentSource( } } -private fun mdocFindCredentialsForRequest( +private suspend fun mdocFindCredentialsForRequest( request: MdocRequest, preSelectedDocument: Document? ): List { diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt index f0d2bed0f..b9dc878e9 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/TestAppUtils.kt @@ -34,18 +34,18 @@ import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.request.DeviceRequestGenerator import com.android.identity.mdoc.util.MdocUtil import com.android.identity.securearea.KeyPurpose -import com.android.identity.securearea.SecureArea import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine -import com.android.identity.storage.StorageEngine import com.android.identity.trustmanagement.TrustManager import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Logger -import com.android.identity.util.fromHex import identitycredential.samples.testapp.generated.resources.Res import identitycredential.samples.testapp.generated.resources.driving_license_card_art +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate @@ -109,10 +109,10 @@ object TestAppUtils { private lateinit var documentData: NameSpacedData lateinit var documentStore: DocumentStore - private val storageEngine: StorageEngine - private val secureArea: SecureArea - private val secureAreaRepository: SecureAreaRepository - private val credentialFactory: CredentialFactory + private val secureAreaRepository: SecureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(platformStorage())) + } + private val credentialFactory: CredentialFactory = CredentialFactory() val documentTypeRepository: DocumentTypeRepository val provisionedDocumentTypes = listOf( @@ -143,22 +143,31 @@ object TestAppUtils { lateinit var issuerTrustManager: TrustManager lateinit var readerTrustManager: TrustManager + private val initJob: Job + init { - storageEngine = EphemeralStorageEngine() - secureAreaRepository = SecureAreaRepository() - secureArea = SoftwareSecureArea(storageEngine) - secureAreaRepository.addImplementation(secureArea) - credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(MdocCredential::class) { - document, dataItem -> MdocCredential(document, dataItem) + document, dataItem -> MdocCredential(document).apply { deserialize(dataItem) } } generateKeysAndCerts() generateTrustManagers() - provisionDocuments() documentTypeRepository = DocumentTypeRepository() documentTypeRepository.addDocumentType(DrivingLicense.getDocumentType()) documentTypeRepository.addDocumentType(PhotoID.getDocumentType()) documentTypeRepository.addDocumentType(EUPersonalID.getDocumentType()) + + initJob = CoroutineScope(Dispatchers.Main).launch { + init() + } + } + + suspend fun init() { + val documentStore = DocumentStore( + platformStorage(), + secureAreaRepository, + credentialFactory + ) + provisionDocuments(documentStore) } private fun generateKeysAndCerts() { @@ -268,13 +277,7 @@ object TestAppUtils { ) } - private fun provisionDocuments() { - documentStore = DocumentStore( - storageEngine, - secureAreaRepository, - credentialFactory - ) - + private suspend fun provisionDocuments(documentStore: DocumentStore) { provisionDocument( "testDrivingLicense", DrivingLicense.getDocumentType(), @@ -304,7 +307,7 @@ object TestAppUtils { // TODO: also provision SD-JWT credentials, if applicable @OptIn(ExperimentalResourceApi::class) - private fun provisionDocument( + private suspend fun provisionDocument( documentId: String, documentType: DocumentType, givenNameOverride: String, @@ -352,22 +355,24 @@ object TestAppUtils { val timeSigned = now - 1.hours val timeValidityBegin = now - 1.hours val timeValidityEnd = now + 24.hours + val secureArea = secureAreaRepository.getImplementation(SoftwareSecureArea.IDENTIFIER)!! val mdocCredential = MdocCredential( document = document, asReplacementFor = null, domain = MDOC_AUTH_KEY_DOMAIN, secureArea = secureArea, - createKeySettings = SoftwareCreateKeySettings.Builder() - .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) - .build(), docType = documentType.mdocDocumentType!!.docType - ) + ).apply { + generateKey(SoftwareCreateKeySettings.Builder() + .setKeyPurposes(setOf(KeyPurpose.SIGN, KeyPurpose.AGREE_KEY)) + .build()) + } // Generate an MSO and issuer-signed data for this authentication key. val msoGenerator = MobileSecurityObjectGenerator( "SHA-256", documentType.mdocDocumentType!!.docType, - mdocCredential.attestation.publicKey + mdocCredential.getAttestation().publicKey ) msoGenerator.setValidityInfo(timeSigned, timeValidityBegin, timeValidityEnd, null) val issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/ServerData.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/ServerData.kt new file mode 100644 index 000000000..4631e9c94 --- /dev/null +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/ServerData.kt @@ -0,0 +1,11 @@ +package com.android.identity.testapp.provisioning + +import com.android.identity.cbor.annotation.CborSerializable + +@CborSerializable +data class ServerData( + val clientId: String, + val deviceAttestationId: String +) { + companion object +} \ No newline at end of file diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/WalletServerProvider.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/WalletServerProvider.kt index 5ec16f5c8..7acf1690a 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/WalletServerProvider.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/provisioning/WalletServerProvider.kt @@ -19,10 +19,10 @@ import com.android.identity.issuance.WalletApplicationCapabilities import com.android.identity.issuance.WalletServer import com.android.identity.issuance.WalletServerImpl import com.android.identity.issuance.register -import com.android.identity.securearea.SecureArea -import com.android.identity.testapp.platformSecureArea +import com.android.identity.storage.StorageTableSpec +import com.android.identity.testapp.platformSecureAreaProvider +import com.android.identity.testapp.platformStorage import com.android.identity.util.Logger -import io.ktor.utils.io.core.toByteArray import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -31,8 +31,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.io.bytestring.ByteString -import kotlinx.io.bytestring.ByteStringBuilder -import kotlinx.io.bytestring.append import kotlin.time.Duration.Companion.seconds /** @@ -42,14 +40,11 @@ class WalletServerProvider( private val baseUrl: String, private val getWalletApplicationCapabilities: suspend () -> WalletApplicationCapabilities, ) { - private val secureArea: SecureArea = platformSecureArea() private val instanceLock = Mutex() private var instance: WalletServer? = null private val issuingAuthorityMap = mutableMapOf() private var notificationsJob: Job? = null - private var clientId: String? = null - private var deviceAttestationId: String? = null companion object { private const val TAG = "WalletServerProvider" @@ -57,15 +52,11 @@ class WalletServerProvider( private val RECONNECT_DELAY_INITIAL = 1.seconds private val RECONNECT_DELAY_MAX = 30.seconds - private val salt = byteArrayOf((0xe7).toByte(), 0x7c, (0xf8).toByte(), (0xec).toByte()) - - fun authenticationMessage(clientId: String, nonce: ByteString): ByteString { - val buffer = ByteStringBuilder() - buffer.append(salt) - buffer.append(clientId.toByteArray()) - buffer.append(nonce) - return buffer.toByteString() - } + private val serverTableSpec = StorageTableSpec( + name = "Servers", + supportPartitions = false, + supportExpiration = false + ) } /** @@ -178,16 +169,30 @@ class WalletServerProvider( flowNotifier = notifier ) + val serverTable = platformStorage().getTable(serverTableSpec) + var serverData = serverTable.get(key = baseUrl)?.let { + ServerData.fromCbor(it.toByteArray()) + } + + val secureAreaProvider = platformSecureAreaProvider() val authentication = walletServer.authenticate() - val challenge = authentication.requestChallenge(clientId ?: "") + val challenge = authentication.requestChallenge(serverData?.clientId ?: "") val deviceAttestation: DeviceAttestation? - if (clientId != challenge.clientId) { + if (serverData?.clientId != challenge.clientId) { // new client for this host - val result = DeviceCheck.generateAttestation(secureArea, challenge.clientId) + val result = DeviceCheck.generateAttestation( + secureArea = secureAreaProvider.get(), + clientId = challenge.clientId + ) deviceAttestation = result.deviceAttestation // TODO: save clientId and deviceAttestationId in storage - clientId = challenge.clientId - deviceAttestationId = result.deviceAttestationId + val newServerData = ServerData(challenge.clientId, result.deviceAttestationId) + if (serverData == null) { + serverTable.insert(baseUrl, ByteString(newServerData.toCbor())) + } else { + serverTable.update(baseUrl, ByteString(newServerData.toCbor())) + } + serverData = newServerData } else { // existing client for this host deviceAttestation = null @@ -195,9 +200,9 @@ class WalletServerProvider( authentication.authenticate(ClientAuthentication( deviceAttestation, DeviceCheck.generateAssertion( - secureArea, - deviceAttestationId!!, - AssertionNonce(challenge.nonce) + secureArea = secureAreaProvider.get(), + deviceAttestationId = serverData.deviceAttestationId, + assertion = AssertionNonce(challenge.nonce) ), getWalletApplicationCapabilities() )) diff --git a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/SoftwareSecureAreaScreen.kt b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/SoftwareSecureAreaScreen.kt index 97a32e82e..2e57e7bc3 100644 --- a/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/SoftwareSecureAreaScreen.kt +++ b/samples/testapp/src/commonMain/kotlin/com/android/identity/testapp/ui/SoftwareSecureAreaScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier @@ -40,18 +41,23 @@ import com.android.identity.crypto.EcCurve import com.android.identity.securearea.KeyLockedException import com.android.identity.securearea.KeyPurpose import com.android.identity.securearea.KeyUnlockData +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.securearea.software.SoftwareCreateKeySettings import com.android.identity.securearea.software.SoftwareKeyUnlockData import com.android.identity.securearea.software.SoftwareSecureArea import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage import com.android.identity.util.Logger import com.android.identity.util.toHex +import kotlinx.coroutines.launch import kotlinx.datetime.Clock import org.jetbrains.compose.ui.tooling.preview.Preview private val TAG = "SoftwareSecureAreaScreen" -private val softwareSecureArea = SoftwareSecureArea(EphemeralStorageEngine()) +private val softwareSecureAreaProvider = SecureAreaProvider { + SoftwareSecureArea.create(EphemeralStorage()) +} private data class swPassphraseTestConfiguration( val keyPurpose: KeyPurpose, @@ -64,6 +70,7 @@ private data class swPassphraseTestConfiguration( fun SoftwareSecureAreaScreen( showToast: (message: String) -> Unit ) { + val coroutineScope = rememberCoroutineScope() val swShowPassphraseDialog = remember { mutableStateOf(null) @@ -76,14 +83,16 @@ fun SoftwareSecureAreaScreen( }, onContinueButtonClicked = { passphraseEnteredByUser: String -> val configuration = swShowPassphraseDialog.value!! - swTest( - configuration.keyPurpose, - configuration.curve, - "1111", - passphraseEnteredByUser, - showToast - ) - swShowPassphraseDialog.value = null; + coroutineScope.launch { + swTest( + configuration.keyPurpose, + configuration.curve, + "1111", + passphraseEnteredByUser, + showToast + ) + swShowPassphraseDialog.value = null + } } ) } @@ -130,13 +139,15 @@ fun SoftwareSecureAreaScreen( swShowPassphraseDialog.value = swPassphraseTestConfiguration(keyPurpose, curve, description) } else { - swTest( - keyPurpose, - curve, - null, - null, - showToast - ) + coroutineScope.launch { + swTest( + keyPurpose, + curve, + null, + null, + showToast + ) + } } }) { @@ -240,7 +251,7 @@ private fun ShowPassphraseDialog( } } -private fun swTest( +private suspend fun swTest( keyPurpose: KeyPurpose, curve: EcCurve, passphrase: String?, @@ -258,7 +269,7 @@ private fun swTest( } } -private fun swTestUnguarded( +private suspend fun swTestUnguarded( keyPurpose: KeyPurpose, curve: EcCurve, passphrase: String?, @@ -271,6 +282,9 @@ private fun swTestUnguarded( if (passphrase != null) { builder.setPassphraseRequired(true, passphrase, null) } + + val softwareSecureArea = softwareSecureAreaProvider.get() + softwareSecureArea.createKey("testKey", builder.build()) var unlockData: KeyUnlockData? = null diff --git a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt index 6541707da..ffc542210 100644 --- a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt +++ b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/PlatformIos.kt @@ -1,9 +1,14 @@ package com.android.identity.testapp +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.driver.NativeSQLiteDriver import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.securearea.SecureEnclaveSecureArea import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.sqlite.SqliteStorage import kotlinx.cinterop.ByteVar import kotlinx.cinterop.ExperimentalForeignApi import kotlinx.cinterop.NativePlacement @@ -16,6 +21,12 @@ import kotlinx.cinterop.ptr import kotlinx.cinterop.reinterpret import kotlinx.cinterop.toKString import kotlinx.cinterop.value +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.newSingleThreadContext +import platform.Foundation.NSDocumentDirectory +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask import platform.darwin.freeifaddrs import platform.darwin.getifaddrs import platform.darwin.ifaddrs @@ -68,12 +79,37 @@ actual fun getLocalIpAddress(): String { throw IllegalStateException("Unable to determine local address") } -private val secureEnclaveStorage = EphemeralStorageEngine() +@OptIn(ExperimentalForeignApi::class) +private fun openDatabase(): SQLiteConnection { + val fileManager = NSFileManager.defaultManager + val rootPath = fileManager.URLForDirectory( + NSDocumentDirectory, + NSUserDomainMask, + appropriateForURL = null, + create = false, + error = null) + ?: throw RuntimeException("could not get documents directory url") + println("Root path: $rootPath") + return NativeSQLiteDriver().open(rootPath.path() + "/storage.db") +} + +@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class) +private val iosStorage = SqliteStorage( + connection = openDatabase(), + // native sqlite crashes when used with Dispatchers.IO + coroutineContext = newSingleThreadContext("DB") +) -private val secureEnclaveSecureArea = SecureEnclaveSecureArea(secureEnclaveStorage) +private val secureEnclaveSecureAreaProvider = SecureAreaProvider { + SecureEnclaveSecureArea.create(iosStorage) +} + +actual fun platformStorage(): Storage { + return iosStorage +} -actual fun platformSecureArea(): SecureArea { - return secureEnclaveSecureArea +actual fun platformSecureAreaProvider(): SecureAreaProvider { + return secureEnclaveSecureAreaProvider } actual fun platformKeySetting(clientId: String): CreateKeySettings { diff --git a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/ui/SecureEnclaveSecureAreaScreenIos.kt b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/ui/SecureEnclaveSecureAreaScreenIos.kt index 27ccfb24e..010936851 100644 --- a/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/ui/SecureEnclaveSecureAreaScreenIos.kt +++ b/samples/testapp/src/iosMain/kotlin/com/android/identity/testapp/ui/SecureEnclaveSecureAreaScreenIos.kt @@ -4,87 +4,104 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve import com.android.identity.securearea.KeyPurpose +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.securearea.SecureEnclaveCreateKeySettings import com.android.identity.securearea.SecureEnclaveKeyUnlockData import com.android.identity.securearea.SecureEnclaveSecureArea import com.android.identity.securearea.SecureEnclaveUserAuthType -import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage import com.android.identity.util.Logger import com.android.identity.util.toHex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import kotlinx.datetime.Clock private val TAG = "SecureEnclaveSecureAreaScreen" -private val secureEnclaveStorage = EphemeralStorageEngine() +private val secureEnclaveStorage = EphemeralStorage() -private val secureEnclaveSecureArea = SecureEnclaveSecureArea(secureEnclaveStorage) +private val secureEnclaveSecureAreaProvider = SecureAreaProvider { + SecureEnclaveSecureArea.create(secureEnclaveStorage) +} @Composable actual fun SecureEnclaveSecureAreaScreen(showToast: (message: String) -> Unit) { + val coroutineScope = rememberCoroutineScope() LazyColumn { item { TextButton( onClick = { seTest(KeyPurpose.SIGN, setOf(), + coroutineScope, showToast) }, content = { Text("P-256 Signature") } ) TextButton( onClick = { seTest(KeyPurpose.SIGN, setOf(SecureEnclaveUserAuthType.DEVICE_PASSCODE), + coroutineScope, showToast) }, content = { Text("P-256 Signature - Auth (Passcode)") } ) TextButton( onClick = { seTest(KeyPurpose.SIGN, setOf(SecureEnclaveUserAuthType.BIOMETRY_CURRENT_SET), + coroutineScope, showToast) }, content = { Text("P-256 Signature - Auth (Biometrics)") } ) TextButton( onClick = { seTest(KeyPurpose.SIGN, setOf(SecureEnclaveUserAuthType.DEVICE_PASSCODE, SecureEnclaveUserAuthType.BIOMETRY_CURRENT_SET), + coroutineScope, showToast) }, content = { Text("P-256 Signature - Auth (Passcode AND Biometrics)") } ) TextButton( onClick = { seTest(KeyPurpose.SIGN, setOf(SecureEnclaveUserAuthType.USER_PRESENCE), + coroutineScope, showToast) }, content = { Text("P-256 Signature - Auth (Passcode OR Biometrics)") } ) TextButton( onClick = { seTest(KeyPurpose.AGREE_KEY, setOf(), + coroutineScope, showToast) }, content = { Text("P-256 Key Agreement") } ) TextButton( onClick = { seTest(KeyPurpose.AGREE_KEY, setOf(SecureEnclaveUserAuthType.DEVICE_PASSCODE), + coroutineScope, showToast) }, content = { Text("P-256 Key Agreement - Auth (Passcode)") } ) TextButton( onClick = { seTest(KeyPurpose.AGREE_KEY, setOf(SecureEnclaveUserAuthType.BIOMETRY_CURRENT_SET), + coroutineScope, showToast) }, content = { Text("P-256 Key Agreement - Auth (Biometrics)") } ) TextButton( onClick = { seTest(KeyPurpose.AGREE_KEY, setOf(SecureEnclaveUserAuthType.DEVICE_PASSCODE, SecureEnclaveUserAuthType.BIOMETRY_CURRENT_SET), + coroutineScope, showToast) }, content = { Text("P-256 Key Agreement - Auth (Passcode AND Biometrics)") } ) TextButton( onClick = { seTest(KeyPurpose.AGREE_KEY, setOf(SecureEnclaveUserAuthType.USER_PRESENCE), + coroutineScope, showToast) }, content = { Text("P-256 Key Agreement - Auth (Passcode OR Biometrics)") } ) @@ -96,21 +113,25 @@ actual fun SecureEnclaveSecureAreaScreen(showToast: (message: String) -> Unit) { private fun seTest( keyPurpose: KeyPurpose, userAuthTypes: Set, + coroutineScope: CoroutineScope, showToast: (message: String) -> Unit ) { - try { - seTestUnguarded(keyPurpose, userAuthTypes, showToast) - } catch (e: Throwable) { - e.printStackTrace(); - showToast("${e.message}") + coroutineScope.launch { + try { + seTestUnguarded(keyPurpose, userAuthTypes, showToast) + } catch (e: Throwable) { + e.printStackTrace(); + showToast("${e.message}") + } } } -private fun seTestUnguarded( +private suspend fun seTestUnguarded( keyPurpose: KeyPurpose, userAuthTypes: Set, showToast: (message: String) -> Unit ) { + val secureEnclaveSecureArea = secureEnclaveSecureAreaProvider.get() secureEnclaveSecureArea.createKey( "testKey", SecureEnclaveCreateKeySettings.Builder() diff --git a/samples/testapp/src/iosX64Main/kotlin/com/android/identity/testapp/Platform.iosX64.kt b/samples/testapp/src/iosX64Main/kotlin/com/android/identity/testapp/Platform.iosX64.kt index d7d0a2acb..c6821e7af 100644 --- a/samples/testapp/src/iosX64Main/kotlin/com/android/identity/testapp/Platform.iosX64.kt +++ b/samples/testapp/src/iosX64Main/kotlin/com/android/identity/testapp/Platform.iosX64.kt @@ -1,3 +1,3 @@ package com.android.identity.testapp -actual val platformIsEmulator: Boolean = false \ No newline at end of file +actual val platformIsEmulator: Boolean = false diff --git a/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt b/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt index eb1bcb16e..a8652cfc5 100644 --- a/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt +++ b/server-env/src/main/java/com/android/identity/server/BaseFlowHttpServlet.kt @@ -8,8 +8,9 @@ import com.android.identity.flow.handler.FlowNotificationsLocalPoll import com.android.identity.flow.handler.HttpHandler import com.android.identity.flow.handler.SimpleCipher import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.flow.transport.HttpTransport +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -28,6 +29,12 @@ import kotlin.random.Random abstract class BaseFlowHttpServlet : BaseHttpServlet() { companion object { const val TAG = "BaseFlowHttpServlet" + + private val flowRootStateTableSpec = StorageTableSpec( + name = "FlowRootState", + supportPartitions = false, + supportExpiration = false + ) } private lateinit var httpHandler: HttpHandler @@ -37,18 +44,17 @@ abstract class BaseFlowHttpServlet : BaseHttpServlet() { buildExceptionMap(exceptionMapBuilder) val dispatcherBuilder = FlowDispatcherLocal.Builder() buildDispatcher(dispatcherBuilder) - val storage = env.getInterface(Storage::class)!! val messageEncryptionKey = runBlocking { - val key = storage.get("RootState", "", "messageEncryptionKey") + val storage = env.getTable(flowRootStateTableSpec) + val key = storage.get("messageEncryptionKey") if (key != null) { key.toByteArray() } else { val newKey = Random.nextBytes(16) storage.insert( - "RootState", - "", - ByteString(newKey), - "messageEncryptionKey") + key = "messageEncryptionKey", + data = ByteString(newKey), + ) newKey } } diff --git a/server-env/src/main/java/com/android/identity/server/SecureAreaStorageAdapter.kt b/server-env/src/main/java/com/android/identity/server/SecureAreaStorageAdapter.kt deleted file mode 100644 index ac0f4ea01..000000000 --- a/server-env/src/main/java/com/android/identity/server/SecureAreaStorageAdapter.kt +++ /dev/null @@ -1,49 +0,0 @@ -package com.android.identity.server - -import com.android.identity.flow.server.Storage -import com.android.identity.storage.StorageEngine -import kotlinx.coroutines.runBlocking -import kotlinx.io.bytestring.ByteString - -internal class SecureAreaStorageAdapter(private val storage: Storage) : StorageEngine { - companion object { - const val TABLE_NAME = "ServerKeys" - } - - override fun get(key: String): ByteArray? { - return runBlocking { - storage.get(TABLE_NAME, "", key)?.toByteArray() - } - } - - override fun put(key: String, data: ByteArray) { - runBlocking { - val dataBytes = ByteString(data) - if (storage.get(TABLE_NAME, "", key) == null) { - storage.insert(TABLE_NAME, "", dataBytes, key) - } else { - storage.update(TABLE_NAME, "", key, dataBytes) - } - } - } - - override fun delete(key: String) { - runBlocking { - storage.delete(TABLE_NAME, "", key) - } - } - - override fun deleteAll() { - runBlocking { - for (key in storage.enumerate(TABLE_NAME, "")) { - storage.delete(TABLE_NAME, "", key) - } - } - } - - override fun enumerate(): Collection { - return runBlocking { - storage.enumerate(TABLE_NAME, "") - } - } -} diff --git a/server-env/src/main/java/com/android/identity/server/ServerEnvironment.kt b/server-env/src/main/java/com/android/identity/server/ServerEnvironment.kt index 481bcb1b3..59b1bc41f 100644 --- a/server-env/src/main/java/com/android/identity/server/ServerEnvironment.kt +++ b/server-env/src/main/java/com/android/identity/server/ServerEnvironment.kt @@ -4,11 +4,13 @@ import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage -import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.securearea.software.SoftwareSecureArea +import com.android.identity.storage.Storage +import com.android.identity.storage.jdbc.JdbcStorage import io.ktor.client.HttpClient import io.ktor.client.engine.java.Java +import java.io.File import kotlin.reflect.KClass import kotlin.reflect.cast @@ -30,21 +32,36 @@ internal class ServerEnvironment( Storage::class -> storage FlowNotifications::class -> notifications HttpClient::class -> httpClient - SecureArea::class -> secureArea + SecureAreaProvider::class -> secureAreaProvider else -> return null }) } } -private val storage = ServerStorage( - CommonConfiguration.getValue("databaseConnection") ?: ServerStorage.defaultDatabase(), +private val storage = JdbcStorage( + CommonConfiguration.getValue("databaseConnection") ?: defaultDatabase(), CommonConfiguration.getValue("databaseUser") ?: "", CommonConfiguration.getValue("databasePassword") ?: "" ) +fun defaultDatabase(): String { + val dbFile = File("environment/db/db.hsqldb").absoluteFile + if (!dbFile.canRead()) { + val parent = File(dbFile.parent) + if (!parent.exists()) { + if (!parent.mkdirs()) { + throw Exception("Cannot create database folder ${parent.absolutePath}") + } + } + } + return "jdbc:hsqldb:file:${dbFile.absolutePath}" +} + private val httpClient = HttpClient(Java) { followRedirects = false } -private val secureArea = SoftwareSecureArea(SecureAreaStorageAdapter(storage)) +private val secureAreaProvider = SecureAreaProvider { + SoftwareSecureArea.create(storage) +} diff --git a/server-env/src/main/java/com/android/identity/server/ServerStorage.kt b/server-env/src/main/java/com/android/identity/server/ServerStorage.kt deleted file mode 100644 index 161013b85..000000000 --- a/server-env/src/main/java/com/android/identity/server/ServerStorage.kt +++ /dev/null @@ -1,158 +0,0 @@ -package com.android.identity.server - -import com.android.identity.flow.server.Storage -import kotlinx.io.bytestring.ByteString -import java.io.File -import java.sql.Connection -import java.sql.DriverManager -import java.util.concurrent.ConcurrentHashMap -import kotlin.io.encoding.Base64 -import kotlin.io.encoding.ExperimentalEncodingApi -import kotlin.random.Random - -internal class ServerStorage( - private val jdbc: String, - private val user: String = "", - private val password: String = "" -): Storage { - private val createdTables = ConcurrentHashMap() - private val blobType: String - - init { - - // initialize appropriate drives (this also ensures that dependencies don't get - // stripped when building WAR file). - if (jdbc.startsWith("jdbc:hsqldb:")) { - blobType = "BLOB" - org.hsqldb.jdbc.JDBCDriver() - } else if (jdbc.startsWith("jdbc:mysql:")) { - blobType = "LONGBLOB" // MySQL BLOB is limited to 64k - com.mysql.cj.jdbc.Driver() - } else { - blobType = "BLOB" - } - } - - override suspend fun get(table: String, peerId: String, key: String): ByteString? { - val safeTable = sanitizeTable(table) - val connection = acquireConnection() - ensureTable(connection, safeTable) - val statement = connection.prepareStatement( - "SELECT data FROM $safeTable WHERE (peerId = ? AND id = ?)") - statement.setString(1, peerId) - statement.setString(2, key) - val resultSet = statement.executeQuery() - if (!resultSet.next()) { - return null - } - val bytes = resultSet.getBytes(1) - releaseConnection(connection) - return ByteString(bytes) - } - - @OptIn(ExperimentalEncodingApi::class) - override suspend fun insert(table: String, peerId: String, data: ByteString, key: String): String { - val safeTable = sanitizeTable(table) - val recordKey = key.ifEmpty { Base64.UrlSafe.encode(Random.Default.nextBytes(18)) } - val connection = acquireConnection() - ensureTable(connection, safeTable) - val statement = connection.prepareStatement("INSERT INTO $safeTable VALUES(?, ?, ?)") - statement.setString(1, recordKey) - statement.setString(2, peerId) - statement.setBytes(3, data.toByteArray()) - val count = statement.executeUpdate() - releaseConnection(connection) - if (count != 1) { - throw IllegalStateException("Value was not inserted") - } - return recordKey - } - - override suspend fun update(table: String, peerId: String, key: String, data: ByteString) { - val safeTable = sanitizeTable(table) - val connection = acquireConnection() - ensureTable(connection, safeTable) - val statement = connection.prepareStatement( - "UPDATE $safeTable SET data = ? WHERE (peerId = ? AND id = ?)") - statement.setBytes(1, data.toByteArray()) - statement.setString(2, peerId) - statement.setString(3, key) - val count = statement.executeUpdate() - releaseConnection(connection) - if (count != 1) { - throw IllegalStateException("Value was not updated") - } - } - - override suspend fun delete(table: String, peerId: String, key: String): Boolean { - val safeTable = sanitizeTable(table) - val connection = acquireConnection() - ensureTable(connection, safeTable) - val statement = connection.prepareStatement( - "DELETE FROM $safeTable WHERE (peerId = ? AND id = ?)") - statement.setString(1, peerId) - statement.setString(2, key) - val count = statement.executeUpdate() - releaseConnection(connection) - return count > 0 - } - - override suspend fun enumerate(table: String, peerId: String, - notBeforeKey: String, limit: Int): List { - val safeTable = sanitizeTable(table) - val connection = acquireConnection() - ensureTable(connection, safeTable) - val opt = if (limit < Int.MAX_VALUE) " LIMIT 0, $limit" else "" - val statement = connection.prepareStatement( - "SELECT id FROM $safeTable WHERE (peerId = ? AND id > ?) ORDER BY id$opt") - statement.setString(1, peerId) - statement.setString(2, notBeforeKey) - val resultSet = statement.executeQuery() - val list = mutableListOf() - while (resultSet.next()) { - list.add(resultSet.getString(1)) - } - releaseConnection(connection) - return list - } - - private fun ensureTable(connection: Connection, safeTable: String) { - if (!createdTables.contains(safeTable)) { - connection.createStatement().execute(""" - CREATE TABLE IF NOT EXISTS $safeTable ( - id VARCHAR(64) PRIMARY KEY, - peerId VARCHAR(64), - data $blobType - ) - """.trimIndent()) - createdTables[safeTable] = true - } - } - - private fun acquireConnection(): Connection { - return DriverManager.getConnection(jdbc, user, password) - } - - private fun releaseConnection(connection: Connection) { - connection.close() - } - - private fun sanitizeTable(table: String): String { - return "Wt$table" - } - - companion object { - fun defaultDatabase(): String { - val dbFile = File("environment/db/db.hsqldb").absoluteFile - if (!dbFile.canRead()) { - val parent = File(dbFile.parent) - if (!parent.exists()) { - if (!parent.mkdirs()) { - throw Exception("Cannot create database folder ${parent.absolutePath}") - } - } - } - return "jdbc:hsqldb:file:${dbFile.absolutePath}" - } - } -} \ No newline at end of file diff --git a/server-env/src/test/java/com/android/identity/server/ServerStorageTest.kt b/server-env/src/test/java/com/android/identity/server/ServerStorageTest.kt deleted file mode 100644 index 48e065997..000000000 --- a/server-env/src/test/java/com/android/identity/server/ServerStorageTest.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.android.identity.server - -import kotlinx.coroutines.runBlocking -import kotlinx.io.bytestring.ByteString -import org.junit.Assert -import org.junit.Test - -class ServerStorageTest { - // NB: this in-memory database is shared across tests. If a particular test requires - // empty database, create on specifically for that test using test name as database name. - private val storage = ServerStorage("jdbc:hsqldb:mem:sharedDb") - - @Test - fun testInsertAndQuery() { - runBlocking { - storage.insert("InsertAndQuery", "client0", ByteString("bad1".toByteArray())) - val key = storage.insert("InsertAndQuery", "client1", ByteString("good".toByteArray())) - storage.insert("InsertAndQuery", "client1", ByteString("bad2".toByteArray())) - storage.insert("InsertAndQuery", "client3", ByteString("bad3".toByteArray())) - Assert.assertNotEquals("", key) - val data = storage.get("InsertAndQuery", "client1", key) - Assert.assertEquals("good", data!!.toByteArray().toString(Charsets.UTF_8)) - } - } - - @Test - fun testEnumerate() { - runBlocking { - (0..19).forEach { i -> - storage.insert("Enumerate", "client0", ByteString("bad$i".toByteArray())) - storage.insert("Enumerate", "client2", ByteString("bad$i".toByteArray())) - } - val keys = (0..19).map { i -> - storage.insert("Enumerate", "client1", ByteString("good$i".toByteArray())) - }.toSet() - Assert.assertEquals(20, keys.size) - val chunk1 = storage.enumerate("Enumerate", "client1", limit = 10) - val chunk2 = storage.enumerate("Enumerate", "client1", - notBeforeKey = chunk1.last(), limit = 12) - Assert.assertEquals(10, chunk1.size) - Assert.assertEquals(10, chunk2.size) - Assert.assertEquals(keys, (chunk1 + chunk2).toSet()) - for (key in keys) { - val data = storage.get("Enumerate", "client1", key)!!.toByteArray() - Assert.assertTrue(data.toString(Charsets.UTF_8).startsWith("good")) - } - } - } - - @Test - fun testUpdate() { - runBlocking { - val key = storage.insert("Update", "client1", ByteString("bad".toByteArray())) - storage.update("Update", "client1", key, ByteString("good".toByteArray())) - val data = storage.get("Update", "client1", key) - Assert.assertEquals("good", data!!.toByteArray().toString(Charsets.UTF_8)) - } - } - - @Test - fun testDelete() { - runBlocking { - val key = storage.insert("Delete", "client1", ByteString("data".toByteArray())) - Assert.assertFalse(storage.delete("Delete", "client0", key)) - Assert.assertFalse(storage.delete("Delete", "client1", "fake_key")) - Assert.assertTrue(storage.delete("Delete", "client1", key)) - val data = storage.get("Delete", "client1", key) - Assert.assertNull(data) - } - } -} \ No newline at end of file diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt index 12d790a51..9a1fcb72d 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeChallengeServlet.kt @@ -1,7 +1,6 @@ package com.android.identity.server.openid4vci -import com.android.identity.flow.handler.InvalidRequestException -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -24,9 +23,9 @@ class AuthorizeChallengeServlet : BaseServlet() { codeToId(OpaqueIdType.AUTH_SESSION, authSession) } val presentation = req.getParameter("presentation_during_issuance_session") - val storage = environment.getInterface(Storage::class)!! val state = runBlocking { - IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) } if (authSession != null) { authorizeWithDpop( diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt index 34732cd9b..95129eebf 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/AuthorizeServlet.kt @@ -4,7 +4,6 @@ import com.android.identity.cbor.Cbor import com.android.identity.cbor.CborArray import com.android.identity.cbor.CborMap import com.android.identity.cbor.Simple -import com.android.identity.cbor.Tstr import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve @@ -13,7 +12,7 @@ import com.android.identity.crypto.EcPublicKeyDoubleCoordinate import com.android.identity.document.NameSpacedData import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.mdoc.response.DeviceResponseParser import com.android.identity.util.fromBase64Url import jakarta.servlet.http.HttpServletRequest @@ -73,14 +72,14 @@ class AuthorizeServlet : BaseServlet() { private fun getOpenid4Vp(code: String, resp: HttpServletResponse) { val id = codeToId(OpaqueIdType.OPENID4VP_CODE, code) val stateRef = idToCode(OpaqueIdType.OPENID4VP_STATE, id, 5.minutes) - val storage = environment.getInterface(Storage::class)!! val responseUri = "$baseUrl/openid4vp-response" val jwt = runBlocking { - val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + val state = IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) val session = initiateOpenid4Vp(state.clientId, responseUri, stateRef) state.pidReadingKey = session.privateKey state.pidNonce = session.nonce - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + storage.update(key = id, data = ByteString(state.toCbor())) session.jwt } resp.contentType = "application/oauth-authz-req+jwt" @@ -94,7 +93,6 @@ class AuthorizeServlet : BaseServlet() { val code = req.getParameter("authorizationCode") val pidData = req.getParameter("pidData") val id = codeToId(OpaqueIdType.AUTHORIZATION_STATE, code) - val storage = environment.getInterface(Storage::class)!! val baseUri = URI(this.baseUrl) val tokenData = Json.parseToJsonElement(pidData).jsonObject["token"]!! @@ -104,7 +102,8 @@ class AuthorizeServlet : BaseServlet() { runBlocking { val origin = baseUri.scheme + "://" + baseUri.authority - val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + val state = IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) val encodedKey = (state.pidReadingKey!!.publicKey as EcPublicKeyDoubleCoordinate).asUncompressedPointEncoding val sessionTranscript = generateBrowserSessionTranscript( Crypto.digest(Algorithm.SHA256, id.toByteArray()), @@ -130,7 +129,7 @@ class AuthorizeServlet : BaseServlet() { } state.credentialData = data.build() - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + storage.update(key = id, data = ByteString(state.toCbor())) } val issuerState = idToCode(OpaqueIdType.ISSUER_STATE, id, 5.minutes) resp.sendRedirect("finish_authorization?issuer_state=$issuerState") diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt index e3571732d..b2e9a786d 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/BaseServlet.kt @@ -9,8 +9,9 @@ import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.handler.SimpleCipher import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.server.BaseHttpServlet +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.fromBase64Url import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest @@ -28,21 +29,26 @@ abstract class BaseServlet: BaseHttpServlet() { companion object { lateinit var cipher: SimpleCipher + + private val rootTableSpec = StorageTableSpec( + name = "Openid4VciServerRootState", + supportExpiration = false, + supportPartitions = false + ) } override fun initializeEnvironment(env: FlowEnvironment): FlowNotifications? { - val storage = env.getInterface(Storage::class)!! val encryptionKey = runBlocking { - val key = storage.get("RootState", "", "issuanceStateEncryptionKey") + val storage = env.getTable(rootTableSpec) + val key = storage.get("issuanceStateEncryptionKey") if (key != null) { key.toByteArray() } else { val newKey = Random.nextBytes(16) storage.insert( - "RootState", - "", - ByteString(newKey), - "issuanceStateEncryptionKey") + data = ByteString(newKey), + key = "issuanceStateEncryptionKey" + ) newKey } } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt index d701cd15e..1f4097cf4 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialRequestServlet.kt @@ -6,7 +6,7 @@ import com.android.identity.crypto.EcCurve import com.android.identity.crypto.EcPublicKeyDoubleCoordinate import com.android.identity.documenttype.knowntypes.EUPersonalID import com.android.identity.flow.handler.InvalidRequestException -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -34,12 +34,12 @@ class CredentialRequestServlet : BaseServlet() { val code = params["code"]?.jsonPrimitive?.content ?: throw InvalidRequestException("missing parameter 'code'") val id = codeToId(OpaqueIdType.PID_READING, code) - val storage = environment.getInterface(Storage::class)!! runBlocking { - val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + val state = IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) val nonce = Crypto.digest(Algorithm.SHA256, id.toByteArray()).toBase64Url() state.pidReadingKey = Crypto.createEcPrivateKey(EcCurve.P256) - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + storage.update(id, ByteString(state.toCbor())) val pidPublicKey = (state.pidReadingKey!!.publicKey as EcPublicKeyDoubleCoordinate) .asUncompressedPointEncoding.toBase64Url() val fullPid = EUPersonalID.getDocumentType().cannedRequests.first { it.id == "full" } diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt index e34e86a58..8bf74d71f 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/CredentialServlet.kt @@ -18,8 +18,8 @@ import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator import com.android.identity.mdoc.mso.StaticAuthDataGenerator import com.android.identity.mdoc.util.MdocUtil @@ -61,16 +61,17 @@ class CredentialServlet : BaseServlet() { } val accessToken = authorization.substring(5) val id = codeToId(OpaqueIdType.ACCESS_TOKEN, accessToken) - val storage = environment.getInterface(Storage::class)!! val state = runBlocking { - IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) } authorizeWithDpop(state.dpopKey, req, state.dpopNonce!!.toByteArray().toBase64Url(), accessToken) val nonce = state.cNonce!!.toByteArray().toBase64Url() // credential nonce state.dpopNonce = null state.cNonce = null runBlocking { - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + val storage = environment.getTable(IssuanceState.tableSpec) + storage.update(id, ByteString(state.toCbor())) } val requestString = String(req.inputStream.readNBytes(req.contentLength)) val json = Json.parseToJsonElement(requestString) as JsonObject diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt index d516b6855..c05117ec5 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/FinishAuthorizationServlet.kt @@ -1,7 +1,7 @@ package com.android.identity.server.openid4vci import com.android.identity.flow.handler.InvalidRequestException -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.runBlocking @@ -19,9 +19,9 @@ class FinishAuthorizationServlet : BaseServlet() { val issuerState = req.getParameter("issuer_state") ?: throw InvalidRequestException("missing parameter 'issuer_state'") val id = codeToId(OpaqueIdType.ISSUER_STATE, issuerState) - val storage = environment.getInterface(Storage::class)!! runBlocking { - val state = IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + val state = IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) val redirectUri = state.redirectUri ?: throw IllegalStateException("No redirect url") if (!redirectUri.startsWith("http://") && !redirectUri.startsWith("https://")) { resp.writer.write( diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt index ea42a6c97..55db962bd 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/Openid4VpResponseServlet.kt @@ -8,7 +8,7 @@ import com.android.identity.crypto.Crypto import com.android.identity.crypto.javaPrivateKey import com.android.identity.crypto.javaPublicKey import com.android.identity.document.NameSpacedData -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.mdoc.response.DeviceResponseParser import com.android.identity.util.fromBase64Url import com.nimbusds.jose.crypto.ECDHDecrypter @@ -29,9 +29,9 @@ class Openid4VpResponseServlet: BaseServlet() { override fun doPost(req: HttpServletRequest, resp: HttpServletResponse) { val stateCode = req.getParameter("state")!! val id = codeToId(OpaqueIdType.OPENID4VP_STATE, stateCode) - val storage = environment.getInterface(Storage::class)!! val state = runBlocking { - IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) } val encryptedJWT = EncryptedJWT.parse(req.getParameter("response")!!) @@ -83,7 +83,8 @@ class Openid4VpResponseServlet: BaseServlet() { state.credentialData = data.build() runBlocking { - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + val storage = environment.getTable(IssuanceState.tableSpec) + storage.update(id, ByteString(state.toCbor())) } val presentation = idToCode(OpaqueIdType.OPENID4VP_PRESENTATION, id, 5.minutes) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt index ae892cfd1..1607e8209 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/ParServlet.kt @@ -1,17 +1,9 @@ package com.android.identity.server.openid4vci -import com.android.identity.crypto.X509Cert -import com.android.identity.flow.handler.InvalidRequestException -import com.android.identity.flow.server.Storage -import com.android.identity.issuance.common.cache -import com.android.identity.sdjwt.util.JsonWebKey -import com.android.identity.util.fromBase64Url import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import kotlinx.coroutines.runBlocking -import kotlinx.io.bytestring.ByteString import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlin.time.Duration.Companion.seconds /** diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt index 15473ff8f..6e810c08e 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/TokenServlet.kt @@ -3,7 +3,7 @@ package com.android.identity.server.openid4vci import com.android.identity.crypto.Algorithm import com.android.identity.crypto.Crypto import com.android.identity.flow.handler.InvalidRequestException -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.util.toBase64Url import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -43,9 +43,9 @@ class TokenServlet : BaseServlet() { else -> throw InvalidRequestException("invalid parameter 'grant_type'") } - val storage = environment.getInterface(Storage::class)!! val state = runBlocking { - IssuanceState.fromCbor(storage.get("IssuanceState", "", id)!!.toByteArray()) + val storage = environment.getTable(IssuanceState.tableSpec) + IssuanceState.fromCbor(storage.get(id)!!.toByteArray()) } if (digest != null) { if (state.codeChallenge == digest) { @@ -62,7 +62,8 @@ class TokenServlet : BaseServlet() { state.redirectUri = null state.cNonce = ByteString(cNonce) runBlocking { - storage.update("IssuanceState", "", id, ByteString(state.toCbor())) + val storage = environment.getTable(IssuanceState.tableSpec) + storage.update(id, ByteString(state.toCbor())) } val expiresIn = 60.minutes val accessToken = idToCode(OpaqueIdType.ACCESS_TOKEN, id, expiresIn) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt index 4c11bef1b..c3614c92a 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/createSession.kt @@ -3,8 +3,8 @@ package com.android.identity.server.openid4vci import com.android.identity.crypto.X509Cert import com.android.identity.flow.handler.InvalidRequestException import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage -import com.android.identity.issuance.common.cache +import com.android.identity.flow.cache +import com.android.identity.flow.server.getTable import com.android.identity.sdjwt.util.JsonWebKey import com.android.identity.util.fromBase64Url import jakarta.servlet.http.HttpServletRequest @@ -63,9 +63,9 @@ suspend fun createSession(environment: FlowEnvironment, req: HttpServletRequest) val dpopKey = JsonWebKey((assertionBody as JsonObject)["cnf"] as JsonObject).asEcPublicKey // Create a session - val storage = environment.getInterface(Storage::class)!! + val storage = environment.getTable(IssuanceState.tableSpec) val state = IssuanceState(clientId, scope, dpopKey, redirectUri, codeChallenge) - return storage.insert("IssuanceState", "", ByteString(state.toCbor())) + return storage.insert(key = null, data = ByteString(state.toCbor())) } private data class ClientCertificate(val certificate: X509Cert) diff --git a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt index 47bf063b4..9ae3346a4 100644 --- a/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt +++ b/server-openid4vci/src/main/java/com/android/identity/server/openid4vci/messages.kt @@ -4,6 +4,7 @@ import com.android.identity.cbor.annotation.CborSerializable import com.android.identity.crypto.EcPrivateKey import com.android.identity.crypto.EcPublicKey import com.android.identity.document.NameSpacedData +import com.android.identity.storage.StorageTableSpec import kotlinx.io.bytestring.ByteString import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -41,7 +42,13 @@ data class IssuanceState( var pidNonce: String? = null, var credentialData: NameSpacedData? = null ) { - companion object + companion object { + val tableSpec = StorageTableSpec( + name = "Openid4VciServerIssuanceState", + supportPartitions = false, + supportExpiration = false + ) + } } /** diff --git a/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt b/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt index 69ddd4871..1bc4cd77e 100644 --- a/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/AdminServlet.kt @@ -8,10 +8,13 @@ import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.handler.SimpleCipher import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.issuance.hardcoded.IssuerDocument import com.android.identity.issuance.hardcoded.IssuingAuthorityState +import com.android.identity.issuance.wallet.AuthenticationState import com.android.identity.server.BaseHttpServlet +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity.util.htmlEscape import io.ktor.utils.io.core.toByteArray @@ -51,29 +54,33 @@ class AdminServlet : BaseHttpServlet() { private fun saltedHash(password: String): ByteString { return ByteString(Crypto.digest(Algorithm.SHA256, "$PASSWORD_SALT$password".toByteArray())) } + + val rootStateTableSpec = StorageTableSpec( + name = "AdminServletRootState", + supportExpiration = false, + supportPartitions = false + ) } override fun initializeEnvironment(env: FlowEnvironment): FlowNotifications? { - val storage = env.getInterface(Storage::class)!! - val messageEncryptionKey = runBlocking { - val key = storage.get("RootState", "", "adminStateEncryptionKey") - if (key != null) { + runBlocking { + val storage = env.getTable(rootStateTableSpec) + val key = storage.get("adminStateEncryptionKey") + val messageEncryptionKey = if (key != null) { key.toByteArray() } else { val newKey = Random.nextBytes(16) storage.insert( - "RootState", - "", - ByteString(newKey), - "adminStateEncryptionKey") + key = "adminStateEncryptionKey", + data = ByteString(newKey), + ) newKey } - } - stateCipher = AesGcmCipher(messageEncryptionKey) - adminPasswordHash = runBlocking { - // Don't write initial password hash in the storage - storage.get("RootState", "", "adminPasswordHash") - ?: saltedHash(servletConfig.getInitParameter("initialAdminPassword")) + stateCipher = AesGcmCipher(messageEncryptionKey) + adminPasswordHash = + // Don't write initial password hash in the storage + storage.get("adminPasswordHash") + ?: saltedHash(servletConfig.getInitParameter("initialAdminPassword")) } // Use notifications from FlowServlet (it must be initialized before AdminServlet) return environmentFor(FlowServlet::class)!!.getInterface(FlowNotifications::class) @@ -173,12 +180,12 @@ class AdminServlet : BaseHttpServlet() { } val hash = saltedHash(password) adminPasswordHash = hash - val storage = environment.getInterface(Storage::class)!! runBlocking { - if (storage.get("RootState", "", "adminPasswordHash") == null) { - storage.insert("RootState", "", hash,"adminPasswordHash") + val storage = environment.getTable(rootStateTableSpec) + if (storage.get("adminPasswordHash") == null) { + storage.insert(key = "adminPasswordHash", data = hash) } else { - storage.update("RootState", "", "adminPasswordHash", hash) + storage.update(key = "adminPasswordHash", data = hash) } } updateAdminPasswordHash(hash) @@ -210,15 +217,15 @@ class AdminServlet : BaseHttpServlet() { val clientId = if (clientIds == null) "" else clientIds[0]!! resp.contentType = "text/html; charset=utf-8" val writer = resp.outputStream.writer(Charset.forName("utf-8")) - val storage = environment.getInterface(Storage::class)!! writer.write(TABLE_HEAD) writer.write("IdDisplay Name") runBlocking { - val documentIds = storage.enumerate("IssuerDocument", clientId) + val storage = environment.getTable(IssuingAuthorityState.documentTableSpec) + val documentIds = storage.enumerate(partitionId = clientId) for (documentId in documentIds) { writer.write("") writer.write("${documentId.htmlEscape()}") - val documentData = storage.get("IssuerDocument", clientId, documentId)!! + val documentData = storage.get(partitionId = clientId, key = documentId)!! val document = IssuerDocument.fromDataItem(Cbor.decode(documentData.toByteArray())) writer.write("${document.documentConfiguration?.displayName?.htmlEscape()}") writer.write("") @@ -230,10 +237,10 @@ class AdminServlet : BaseHttpServlet() { "clients.html" -> { resp.contentType = "text/html; charset=utf-8" val writer = resp.outputStream.writer(Charset.forName("utf-8")) - val storage = environment.getInterface(Storage::class)!! writer.write(LIST_HEAD) runBlocking { - val clients = storage.enumerate("ClientKeys", "") + val storage = environment.getTable(AuthenticationState.clientTableSpec) + val clients = storage.enumerate() for (client in clients) { val escaped = client.htmlEscape() val urlenc = URLEncoder.encode(client, "utf-8") diff --git a/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt b/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt index 1c9daecf6..c85346bd2 100644 --- a/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/CloudSecureAreaServlet.kt @@ -15,12 +15,12 @@ import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.issuance.WalletServerSettings import com.android.identity.securearea.cloud.CloudSecureAreaServer import com.android.identity.securearea.cloud.SimplePassphraseFailureEnforcer import com.android.identity.server.BaseHttpServlet -import com.android.identity.util.fromHex +import com.android.identity.storage.StorageTableSpec import kotlinx.coroutines.runBlocking import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse @@ -160,17 +160,19 @@ class CloudSecureAreaServlet : BaseHttpServlet() { private lateinit var cloudSecureArea: CloudSecureAreaServer private lateinit var keyMaterial: KeyMaterial + private val cloudRootStateTableSpec = StorageTableSpec( + name = "CloudSecureAreaRootState", + supportExpiration = false, + supportPartitions = false + ) + private fun createKeyMaterial(serverEnvironment: FlowEnvironment): KeyMaterial { - val storage = serverEnvironment.getInterface(Storage::class)!! val keyMaterialBlob = runBlocking { - storage.get("RootState", "", "cloudSecureAreaKeyMaterial")?.toByteArray() + val storage = serverEnvironment.getTable(cloudRootStateTableSpec) + storage.get("cloudSecureAreaKeyMaterial")?.toByteArray() ?: let { val blob = KeyMaterial.createKeyMaterial(serverEnvironment).toCbor() - storage.insert( - "RootState", - "", - ByteString(blob), - "cloudSecureAreaKeyMaterial") + storage.insert(key = "cloudSecureAreaKeyMaterial", data = ByteString(blob)) blob } } @@ -214,7 +216,9 @@ class CloudSecureAreaServlet : BaseHttpServlet() { val requestLength = req.contentLength val requestData = req.inputStream.readNBytes(requestLength) val remoteHost = getRemoteHost(req) - val (first, second) = cloudSecureArea.handleCommand(requestData, remoteHost) + val (first, second) = runBlocking { + cloudSecureArea.handleCommand(requestData, remoteHost) + } resp.status = first if (first == HttpServletResponse.SC_OK) { resp.contentType = "application/cbor" diff --git a/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt b/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt index 273fe16d3..abd443170 100644 --- a/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/LandingServlet.kt @@ -4,7 +4,7 @@ import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.issuance.LandingUrlNotification import com.android.identity.issuance.wallet.ApplicationSupportState import com.android.identity.issuance.wallet.LandingRecord @@ -45,15 +45,15 @@ class LandingServlet: BaseHttpServlet() { return } val id = rawPath.substring(1) - val storage = environment.getInterface(Storage::class)!! runBlocking { - val recordData = storage.get("Landing", "", id) + val storage = environment.getTable(ApplicationSupportState.landingTableSpec) + val recordData = storage.get(id) if (recordData == null) { resp.sendError(404) } else { val record = LandingRecord.fromCbor(recordData.toByteArray()) record.resolved = req.queryString ?: "" - storage.update("Landing", "", id, ByteString(record.toCbor())) + storage.update(id, ByteString(record.toCbor())) val configuration = environment.getInterface(Configuration::class)!! val baseUrl = configuration.getValue("base_url") val landingUrl = "$baseUrl/${ApplicationSupportState.URL_PREFIX}$id" diff --git a/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt b/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt index 4312be4d8..e163e93a5 100644 --- a/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt +++ b/server/src/main/java/com/android/identity/wallet/server/VerifierServlet.kt @@ -31,13 +31,15 @@ import com.android.identity.documenttype.knowntypes.UtopiaNaturalization import com.android.identity.flow.handler.FlowNotifications import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.FlowEnvironment -import com.android.identity.flow.server.Storage +import com.android.identity.flow.server.getTable import com.android.identity.mdoc.request.DeviceRequestGenerator import com.android.identity.mdoc.response.DeviceResponseParser import com.android.identity.mdoc.util.MdocUtil import com.android.identity.sdjwt.presentation.SdJwtVerifiablePresentation import com.android.identity.sdjwt.vc.JwtBody import com.android.identity.server.BaseHttpServlet +import com.android.identity.storage.StorageTable +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity.util.fromBase64Url import com.android.identity.util.fromHex @@ -80,6 +82,7 @@ import java.security.interfaces.ECPrivateKey import java.security.interfaces.ECPublicKey import kotlin.io.encoding.ExperimentalEncodingApi import kotlin.random.Random +import kotlin.time.Duration.Companion.days enum class Protocol { W3C_DC_PREVIEW, @@ -128,7 +131,6 @@ private data class OpenID4VPResultLine( @CborSerializable data class Session( - val id: String, val requestFormat: String, // "mdoc" or "vc" val requestDocType: String, // mdoc DocType or VC vct val requestId: String, // DocumentWellKnownRequest.id @@ -263,23 +265,35 @@ class VerifierServlet : BaseHttpServlet() { companion object { private const val TAG = "VerifierServlet" - private const val STORAGE_TABLE_NAME = "VerifierSessions" + val SESSION_EXPIRATION_INTERVAL = 1.days + + private val verifierSessionTableSpec = StorageTableSpec( + name = "VerifierSessions", + supportPartitions = false, + supportExpiration = true + ) + + private val verifierRootStateTableSpec = StorageTableSpec( + name = "VerifierRootState", + supportPartitions = false, + supportExpiration = false + ) private lateinit var keyMaterial: KeyMaterial private lateinit var configuration: Configuration - private lateinit var storage: Storage + private lateinit var verifierSessionTable: StorageTable + private lateinit var verifierRootStateTable: StorageTable private fun createKeyMaterial(serverEnvironment: FlowEnvironment): KeyMaterial { - val storage = serverEnvironment.getInterface(Storage::class)!! val keyMaterialBlob = runBlocking { - storage.get("RootState", "", "verifierKeyMaterial")?.toByteArray() + verifierRootStateTable = serverEnvironment.getTable(verifierRootStateTableSpec) + verifierSessionTable = serverEnvironment.getTable(verifierSessionTableSpec) + verifierRootStateTable.get("verifierKeyMaterial")?.toByteArray() ?: let { val blob = KeyMaterial.createKeyMaterial().toCbor() - storage.insert( - "RootState", - "", - ByteString(blob), - "verifierKeyMaterial" + verifierRootStateTable.insert( + key = "verifierKeyMaterial", + data = ByteString(blob), ) blob } @@ -302,7 +316,6 @@ class VerifierServlet : BaseHttpServlet() { override fun initializeEnvironment(env: FlowEnvironment): FlowNotifications? { configuration = env.getInterface(Configuration::class)!! - storage = env.getInterface(Storage::class)!! keyMaterial = createKeyMaterial(env) return null } @@ -521,7 +534,6 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= // Create a new session val session = Session( - id = Random.Default.nextBytes(16).toHex(), nonce = Random.Default.nextBytes(16).toHex(), origin = request.origin, encryptionKey = Crypto.createEcPrivateKey(EcCurve.P256), @@ -530,12 +542,11 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= requestId = request.requestId, protocol = protocol ) - runBlocking { - storage.insert( - STORAGE_TABLE_NAME, - "", - ByteString(session.toCbor()), - session.id + val sessionId = runBlocking { + verifierSessionTable.insert( + key = null, + data = ByteString(session.toCbor()), + expiration = Clock.System.now() + SESSION_EXPIRATION_INTERVAL ) } @@ -557,7 +568,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= ) Logger.i(TAG, "dcRequestString: $dcRequestString") val json = Json { ignoreUnknownKeys = true } - val responseString = json.encodeToString(DCBeginResponse(session.id, dcRequestString)) + val responseString = json.encodeToString(DCBeginResponse(sessionId, dcRequestString)) resp.status = HttpServletResponse.SC_OK resp.outputStream.write(responseString.encodeToByteArray()) resp.contentType = "application/json" @@ -573,11 +584,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= val request = Json.decodeFromString(requestString) val encodedSession = runBlocking { - storage.get( - STORAGE_TABLE_NAME, - "", - request.sessionId - ) + verifierSessionTable.get(request.sessionId) } if (encodedSession == null) { Logger.e(TAG, "$remoteHost: No session for sessionId ${request.sessionId}") @@ -710,7 +717,6 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= // Create a new session val session = Session( - id = Random.Default.nextBytes(16).toHex(), nonce = Random.Default.nextBytes(16).toHex(), origin = request.origin, encryptionKey = Crypto.createEcPrivateKey(EcCurve.P256), @@ -719,12 +725,11 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= requestId = request.requestId, protocol = protocol ) - runBlocking { - storage.insert( - STORAGE_TABLE_NAME, - "", - ByteString(session.toCbor()), - session.id + val sessionId = runBlocking { + verifierSessionTable.insert( + key = null, + data = ByteString(session.toCbor()), + expiration = Clock.System.now() + SESSION_EXPIRATION_INTERVAL ) } @@ -739,7 +744,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= return } } - val requestUri = baseUrl + "/verifier/openid4vpRequest?sessionId=${session.id}" + val requestUri = baseUrl + "/verifier/openid4vpRequest?sessionId=${sessionId}" val uri = uriScheme + "?client_id=" + URLEncoder.encode(clientId, Charsets.UTF_8) + "&request_uri=" + URLEncoder.encode(requestUri, Charsets.UTF_8) @@ -765,11 +770,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= return } val encodedSession = runBlocking { - storage.get( - STORAGE_TABLE_NAME, - "", - sessionId - ) + verifierSessionTable.get(sessionId) } if (encodedSession == null) { Logger.e(TAG, "$remoteHost: No session for sessionId $sessionId") @@ -778,7 +779,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= } val session = Session.fromCbor(encodedSession.toByteArray()) - val responseUri = baseUrl + "/verifier/openid4vpResponse?sessionId=${session.id}" + val responseUri = baseUrl + "/verifier/openid4vpResponse?sessionId=${sessionId}" val (singleUseReaderKeyPriv, singleUseReaderKeyCertChain) = createSingleUseReaderKey() @@ -817,7 +818,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= .claim("response_type", "vp_token") .claim("response_mode", "direct_post.jwt") .claim("nonce", session.nonce) - .claim("state", session.id) + .claim("state", sessionId) .claim("presentation_definition", presentationDefinition) .claim("client_metadata", calcClientMetadata(session, session.requestFormat)) .build() @@ -843,11 +844,10 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= // We'll need responseUri later (to calculate sessionTranscript) session.responseUri = responseUri runBlocking { - storage.update( - STORAGE_TABLE_NAME, - "", - sessionId, - ByteString(session.toCbor()) + verifierSessionTable.update( + key = sessionId, + data = ByteString(session.toCbor()), + expiration = Clock.System.now() + SESSION_EXPIRATION_INTERVAL ) } @@ -877,11 +877,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= return } val encodedSession = runBlocking { - storage.get( - STORAGE_TABLE_NAME, - "", - sessionId - ) + verifierSessionTable.get(sessionId) } if (encodedSession == null) { Logger.e(TAG, "$remoteHost: No session for sessionId $sessionId") @@ -948,11 +944,10 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= // Save `deviceResponse` and `sessionTranscript`, for later runBlocking { - storage.update( - STORAGE_TABLE_NAME, - "", - sessionId, - ByteString(session.toCbor()) + verifierSessionTable.update( + key = sessionId, + data = ByteString(session.toCbor()), + expiration = Clock.System.now() + SESSION_EXPIRATION_INTERVAL ) } @@ -962,7 +957,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= return } - val redirectUri = baseUrl + "/verifier_redirect.html?sessionId=${session.id}" + val redirectUri = baseUrl + "/verifier_redirect.html?sessionId=${sessionId}" val json = Json { ignoreUnknownKeys = true } resp.outputStream.write( json.encodeToString(OpenID4VPRedirectUriResponse(redirectUri)) @@ -982,11 +977,7 @@ lrW+vvdmRHBgS+ss56uWyYor6W7ah9ygBwYFK4EEACI= val request = Json.decodeFromString(requestString) val encodedSession = runBlocking { - storage.get( - STORAGE_TABLE_NAME, - "", - request.sessionId - ) + verifierSessionTable.get(request.sessionId) } if (encodedSession == null) { Logger.e(TAG, "$remoteHost: No session for sessionId ${request.sessionId}") diff --git a/settings.gradle.kts b/settings.gradle.kts index 448f62041..0fa6072b9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,12 +46,12 @@ include(":identity-mdoc") include(":identity-sdjwt") include(":identity-doctypes") include(":identity-android") -include(":identity-android-legacy") include(":identity-issuance") include(":identity-issuance-api") include(":identity-appsupport") include(":identity-csa") include(":identity-android-csa") +include(":identity-android-legacy") include(":identityctl") include(":mrtd-reader") include(":mrtd-reader-android") diff --git a/wallet/src/androidTest/java/com/android/identity/remote/StorageImplTest.kt b/wallet/src/androidTest/java/com/android/identity/remote/StorageImplTest.kt deleted file mode 100644 index 406d99d7e..000000000 --- a/wallet/src/androidTest/java/com/android/identity/remote/StorageImplTest.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.android.identity.remote - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import com.android.identity.flow.server.Storage -import com.android.identity.issuance.remote.StorageImpl -import kotlinx.coroutines.runBlocking -import kotlinx.io.bytestring.ByteString -import org.junit.Assert -import org.junit.Before -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class StorageImplTest { - lateinit var storage: Storage - - @Before - fun initDb() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - storage = StorageImpl(appContext, "test") - } - - @Test - fun insertAndQuery() { - runBlocking { - storage.insert("InsertAndQuery", "client0", ByteString("bad1".toByteArray())) - val key = storage.insert("InsertAndQuery", "client1", ByteString("good".toByteArray())) - storage.insert("InsertAndQuery", "client1", ByteString("bad2".toByteArray())) - storage.insert("InsertAndQuery", "client3", ByteString("bad3".toByteArray())) - Assert.assertNotEquals("", key) - val data = storage.get("InsertAndQuery", "client1", key) - Assert.assertEquals("good", data!!.toByteArray().toString(Charsets.UTF_8)) - } - } - - @Test - fun testEnumerate() { - runBlocking { - (0..19).forEach { i -> - storage.insert("Enumerate", "client0", ByteString("bad$i".toByteArray())) - storage.insert("Enumerate", "client2", ByteString("bad$i".toByteArray())) - } - val keys = (0..19).map { i -> - storage.insert("Enumerate", "client1", ByteString("good$i".toByteArray())) - }.toSet() - Assert.assertEquals(20, keys.size) - val chunk1 = storage.enumerate("Enumerate", "client1", limit = 10) - val chunk2 = storage.enumerate("Enumerate", "client1", - notBeforeKey = chunk1.last(), limit = 12) - Assert.assertEquals(10, chunk1.size) - Assert.assertEquals(10, chunk2.size) - Assert.assertEquals(keys, (chunk1 + chunk2).toSet()) - for (key in keys) { - val data = storage.get("Enumerate", "client1", key)!!.toByteArray() - Assert.assertTrue(data.toString(Charsets.UTF_8).startsWith("good")) - } - } - } - - @Test - fun testUpdate() { - runBlocking { - val key = storage.insert("Update", "client1", ByteString("bad".toByteArray())) - storage.update("Update", "client1", key, ByteString("good".toByteArray())) - val data = storage.get("Update", "client1", key) - Assert.assertEquals("good", data!!.toByteArray().toString(Charsets.UTF_8)) - } - } - - @Test - fun testDelete() { - runBlocking { - val key = storage.insert("Delete", "client1", ByteString("data".toByteArray())) - Assert.assertFalse(storage.delete("Delete", "client0", key)) - Assert.assertFalse(storage.delete("Delete", "client1", "fake_key")) - Assert.assertTrue(storage.delete("Delete", "client1", key)) - val data = storage.get("Delete", "client1", key) - Assert.assertNull(data) - } - } -} \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt index 4aff6b76a..bbbf2678f 100644 --- a/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt +++ b/wallet/src/main/java/com/android/identity/issuance/remote/LocalDevelopmentEnvironment.kt @@ -8,11 +8,13 @@ import androidx.annotation.RawRes import com.android.identity.device.DeviceAssertionMaker import com.android.identity.flow.server.Configuration import com.android.identity.flow.server.Resources -import com.android.identity.flow.server.Storage import com.android.identity.flow.server.FlowEnvironment import com.android.identity.flow.handler.FlowNotifications import com.android.identity.issuance.ApplicationSupport import com.android.identity.securearea.SecureArea +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity_credential.wallet.R import com.android.identity_credential.wallet.SettingsModel import io.ktor.client.HttpClient @@ -20,6 +22,7 @@ import io.ktor.client.engine.android.Android import kotlinx.coroutines.runBlocking import kotlinx.io.bytestring.ByteString import java.io.ByteArrayOutputStream +import java.io.File import java.nio.charset.StandardCharsets import kotlin.reflect.KClass import kotlin.reflect.cast @@ -33,12 +36,14 @@ internal class LocalDevelopmentEnvironment( context: Context, settingsModel: SettingsModel, private val assertionMaker: DeviceAssertionMaker, - private val secureArea: SecureArea, + private val secureAreaProvider: SecureAreaProvider, private val notifications: FlowNotifications, private val applicationSupportSupplier: WalletServerProvider.ApplicationSupportSupplier ) : FlowEnvironment { private var configuration = ConfigurationImpl(context, settingsModel) - private val storage = StorageImpl(context, "dev_local_data") + private val storage = AndroidStorage( + File(context.dataDir, "local_dev.db").absolutePath + ) private val resources = ResourcesImpl(context) private val httpClient = HttpClient(Android) { followRedirects = false @@ -60,7 +65,7 @@ internal class LocalDevelopmentEnvironment( Storage::class -> storage FlowNotifications::class -> notifications HttpClient::class -> httpClient - SecureArea::class -> secureArea + SecureAreaProvider::class -> secureAreaProvider DeviceAssertionMaker::class -> assertionMaker ApplicationSupport::class -> runBlocking { // We do not want to attempt to obtain applicationSupport ahead of time diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/StorageImpl.kt b/wallet/src/main/java/com/android/identity/issuance/remote/StorageImpl.kt deleted file mode 100644 index 0b6adc647..000000000 --- a/wallet/src/main/java/com/android/identity/issuance/remote/StorageImpl.kt +++ /dev/null @@ -1,176 +0,0 @@ -package com.android.identity.issuance.remote - -import android.content.ContentValues -import android.content.Context -import android.database.AbstractWindowedCursor -import android.database.CursorWindow -import android.database.sqlite.SQLiteDatabase -import android.os.Build -import com.android.identity.flow.server.Storage -import com.android.identity.util.Logger -import kotlinx.io.bytestring.ByteString -import java.util.UUID -import java.util.concurrent.Executors -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -internal class StorageImpl( - private val context: Context, - private val databaseName: String -) : Storage { - private var database: SQLiteDatabase? = null - private val executor = Executors.newSingleThreadExecutor()!! - private val createdTables = mutableSetOf() - - companion object { - const val TAG = "StorageImpl" - } - - private suspend fun runInExecutor(block: (database: SQLiteDatabase) -> T): T { - return suspendCoroutine { continuation -> - Logger.i(TAG, "Submitting database task") - executor.submit { - Logger.i(TAG, "Database task started") - try { - if (database == null) { - database = openDatabase() - } - val result = block(database!!) - Logger.i(TAG, "Database task finished") - continuation.resume(result) - } catch (ex: Throwable) { - Logger.e(TAG, "Database task error", ex) - continuation.resumeWithException(ex) - } - } - Logger.i(TAG, "Database task submitted") - } - } - - override suspend fun get(table: String, peerId: String, key: String): ByteString? { - val safeTable = sanitizeTable(table) - return runInExecutor { database -> - ensureTable(safeTable) - val cursor = database.query( - safeTable, - arrayOf("data"), - "peerId = ? AND key = ?", - arrayOf(peerId, key), - null, - null, - null - ) - // TODO: Older OS versions don't support setting the cursor window size. - // What should we do with older OS versions? - // Also note that a large window size may lead to longer delays when loading from the - // database. And if we keep this, replace the magic number with a constant. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - // The default window size of 2MB is too small for video files. - (cursor as? AbstractWindowedCursor)?.window = CursorWindow( - "Larger Window", 256 * 1024 * 1024) - } - if (cursor.moveToFirst()) { - val bytes = cursor.getBlob(0) - cursor.close() - ByteString(bytes) - } else { - null - } - } - } - - override suspend fun insert(table: String, peerId: String, data: ByteString, key: String): String { - val safeTable = sanitizeTable(table) - return runInExecutor { database -> - val recordKey = key.ifEmpty { UUID.randomUUID().toString() } - ensureTable(safeTable) - val rowId = database.insert(safeTable, null, ContentValues().apply { - put("key", recordKey) - put("peerId", peerId) - put("data", data.toByteArray()) - }) - if (rowId < 0) { - throw IllegalStateException("Value was not inserted") - } - recordKey - } - } - - override suspend fun update(table: String, peerId: String, key: String, data: ByteString) { - val safeTable = sanitizeTable(table) - return runInExecutor { database -> - ensureTable(safeTable) - val count = database.update( - safeTable, - ContentValues().apply { - put("data", data.toByteArray()) - }, - "peerId = ? AND key = ?", - arrayOf(peerId, key) - ) - if (count != 1) { - throw IllegalStateException("Value was not updated") - } - } - } - - override suspend fun delete(table: String, peerId:String, key: String): Boolean { - val safeTable = sanitizeTable(table) - return runInExecutor { database -> - ensureTable(safeTable) - val count = database.delete(safeTable, "peerId = ? AND key = ?", - arrayOf(peerId, key)) - count > 0 - } - } - - override suspend fun enumerate(table: String, peerId: String, - notBeforeKey: String, limit: Int): List { - val safeTable = sanitizeTable(table) - return runInExecutor { database -> - ensureTable(safeTable) - val cursor = database.query( - safeTable, - arrayOf("key"), - "peerId = ? AND key > ?", - arrayOf(peerId, notBeforeKey), - null, - null, - "key", - if (limit < Int.MAX_VALUE) "0, $limit" else null - ) - val list = mutableListOf() - while (cursor.moveToNext()) { - list.add(cursor.getString(0)) - } - cursor.close() - list - } - } - - private fun ensureTable(safeTable: String) { - if (!createdTables.contains(safeTable)) { - database!!.execSQL(""" - CREATE TABLE IF NOT EXISTS $safeTable ( - key TEXT PRIMARY KEY, - peerId TEXT, - data BLOB - ) - """.trimIndent()) - createdTables.add(safeTable) - } - } - - private fun openDatabase(): SQLiteDatabase { - val file = context.getDatabasePath(databaseName) - val params = SQLiteDatabase.OpenParams.Builder() - .setOpenFlags(SQLiteDatabase.CREATE_IF_NECESSARY) - .build() - return SQLiteDatabase.openDatabase(file, params); - } - - private fun sanitizeTable(table: String): String { - return "St_$table" - } -} \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt b/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt index a4e12c3af..affef39c7 100644 --- a/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt +++ b/wallet/src/main/java/com/android/identity/issuance/remote/WalletServerProvider.kt @@ -25,6 +25,9 @@ import com.android.identity.issuance.WalletServerImpl import com.android.identity.issuance.wallet.WalletServerState import com.android.identity.device.DeviceCheck import com.android.identity.device.DeviceAttestation +import com.android.identity.securearea.SecureAreaProvider +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import com.android.identity_credential.wallet.SettingsModel import kotlinx.coroutines.CoroutineScope @@ -39,7 +42,6 @@ import kotlinx.coroutines.withContext import kotlinx.io.bytestring.ByteString import kotlin.time.Duration.Companion.seconds - /** * An object used to connect to a remote wallet server. * @@ -61,7 +63,8 @@ import kotlin.time.Duration.Companion.seconds */ class WalletServerProvider( private val context: Context, - private val secureArea: AndroidKeystoreSecureArea, + private val storage: Storage, + private val secureAreaProvider: SecureAreaProvider, private val settingsModel: SettingsModel, private val getWalletApplicationCapabilities: suspend () -> WalletApplicationCapabilities ) { @@ -73,12 +76,10 @@ class WalletServerProvider( private var notificationsJob: Job? = null private var resetListeners = mutableListOf<()->Unit>() - private val storage = StorageImpl(context, "wallet_servers") - val assertionMaker = DeviceAssertionMaker { assertionFactory -> val applicationSupportConnection = applicationSupportSupplier!!.getApplicationSupport() DeviceCheck.generateAssertion( - secureArea = secureArea, + secureArea = secureAreaProvider.get(), deviceAttestationId = applicationSupportConnection.deviceAttestationId, assertion = assertionFactory(applicationSupportConnection.clientId) ) @@ -99,6 +100,12 @@ class WalletServerProvider( return ciphertext } } + + private val hostsTableSpec = StorageTableSpec( + name = "Hosts", + supportExpiration = false, + supportPartitions = false + ) } private val baseUrl: String @@ -261,7 +268,7 @@ class WalletServerProvider( } val environment = LocalDevelopmentEnvironment( context, settingsModel, assertionMaker, - secureArea, notifier, applicationSupportSupplier) + secureAreaProvider, notifier, applicationSupportSupplier) dispatcher = WrapperFlowDispatcher(builder.build( environment, noopCipher, @@ -286,11 +293,8 @@ class WalletServerProvider( flowNotifier = notifier ) - val connectionDataBytes = storage.get( - table = "Hosts", - peerId = "", - key = baseUrl - ) + val hostsTable = storage.getTable(hostsTableSpec) + val connectionDataBytes = hostsTable.get(key = baseUrl) var connectionData = if (connectionDataBytes == null) { null } else { @@ -301,23 +305,22 @@ class WalletServerProvider( val deviceAttestation: DeviceAttestation? if (connectionData?.clientId != challenge.clientId) { // new client - val result = DeviceCheck.generateAttestation(secureArea, challenge.clientId) + val result = DeviceCheck.generateAttestation( + secureAreaProvider.get(), + challenge.clientId + ) deviceAttestation = result.deviceAttestation connectionData = WalletServerConnectionData( clientId = challenge.clientId, deviceAttestationId = result.deviceAttestationId ) if (connectionDataBytes == null) { - storage.insert( - table = "Hosts", - peerId = "", + hostsTable.insert( data = ByteString(connectionData.toCbor()), key = baseUrl ) } else { - storage.update( - table = "Hosts", - peerId = "", + hostsTable.update( data = ByteString(connectionData.toCbor()), key = baseUrl ) @@ -328,7 +331,7 @@ class WalletServerProvider( authentication.authenticate(ClientAuthentication( deviceAttestation, DeviceCheck.generateAssertion( - secureArea, + secureAreaProvider.get(), connectionData.deviceAttestationId, AssertionNonce(challenge.nonce) ), diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt index 4da6f437d..e2cd58d5a 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/DocumentModel.kt @@ -79,7 +79,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.datetime.Clock import kotlinx.io.bytestring.ByteString @@ -160,13 +159,13 @@ class DocumentModel( ) } - fun deleteCard(documentInfo: DocumentInfo) { + suspend fun deleteCard(documentInfo: DocumentInfo) { val document = documentStore.lookupDocument(documentInfo.documentId) if (document == null) { Logger.w(TAG, "No document with id ${documentInfo.documentId}") - return + } else { + documentStore.deleteDocument(document.name) } - documentStore.deleteDocument(document.name) } fun attachToActivity(activity: FragmentActivity) { @@ -183,7 +182,7 @@ class DocumentModel( return context.resources.getString(getStrId) } - fun getEventInfos(documentId: String): List { + suspend fun getEventInfos(documentId: String): List { val eventLogger = walletApplication.eventLogger val events = eventLogger.getEntries(documentId) @@ -221,12 +220,12 @@ class DocumentModel( } } - fun deleteEventInfos(documentId: String) { + suspend fun deleteEventInfos(documentId: String) { val eventLogger = walletApplication.eventLogger eventLogger.deleteEntriesForDocument(documentId) } - private fun createCardForDocument(document: Document): DocumentInfo? { + private suspend fun createCardForDocument(document: Document): DocumentInfo? { val documentConfiguration = document.documentConfiguration val options = BitmapFactory.Options() options.inMutable = true @@ -311,7 +310,7 @@ class DocumentModel( ) } - private fun addSecureAreaBoundCredentialInfo( + private suspend fun addSecureAreaBoundCredentialInfo( credential: SecureAreaBoundCredential, kvPairs: MutableMap ) { @@ -408,7 +407,7 @@ class DocumentModel( } } - private fun createCardInfoForMdocCredential(mdocCredential: MdocCredential): CredentialInfo { + private suspend fun createCardInfoForMdocCredential(mdocCredential: MdocCredential): CredentialInfo { val credentialData = StaticAuthDataParser(mdocCredential.issuerProvidedData).parse() val issuerAuthCoseSign1 = Cbor.decode(credentialData.issuerAuth).asCoseSign1 @@ -439,7 +438,7 @@ class DocumentModel( ) } - private fun createCardInfoForSdJwtVcCredential(sdJwtVcCredential: Credential): CredentialInfo { + private suspend fun createCardInfoForSdJwtVcCredential(sdJwtVcCredential: Credential): CredentialInfo { val kvPairs = mutableMapOf() @@ -474,7 +473,7 @@ class DocumentModel( } - private fun addDocument(document: Document) { + private suspend fun addDocument(document: Document) { createCardForDocument(document)?.let { documentInfos.add(it) } } @@ -487,7 +486,7 @@ class DocumentModel( documentInfos.removeAt(cardIndex) } - private fun updateDocument(document: Document) { + private suspend fun updateDocument(document: Document) { val cardIndex = documentInfos.indexOfFirst { it.documentId == document.name } if (cardIndex < 0) { Logger.w(TAG, "No card for document with id ${document.name}") @@ -496,7 +495,7 @@ class DocumentModel( createCardForDocument(document)?.let { documentInfos[cardIndex] = it } } - private fun updateCredman() { + private suspend fun updateCredman() { CredmanRegistry.registerCredentials(context, documentStore, documentTypeRepository) } @@ -523,51 +522,45 @@ class DocumentModel( startListeningForNotifications(issuingAuthorityId) } - // This ensure we process both flows sequentially, that is, that calls modifying our - // model (addDocument, removeDocument, updateDocument) doesn't happen at - // the same time... - // - runBlocking { - documentStore.eventFlow - .onEach { (eventType, document) -> - Logger.i(TAG, "DocumentStore event $eventType ${document.name}") - when (eventType) { - DocumentStore.EventType.DOCUMENT_ADDED -> { - addDocument(document) - if (!issuingAuthorityIdSet.contains(document.issuingAuthorityIdentifier)) { - issuingAuthorityIdSet.add(document.issuingAuthorityIdentifier) - startListeningForNotifications(document.issuingAuthorityIdentifier) - } - updateCredman() + documentStore.eventFlow + .onEach { (eventType, document) -> + Logger.i(TAG, "DocumentStore event $eventType ${document.name}") + when (eventType) { + DocumentStore.EventType.DOCUMENT_ADDED -> { + addDocument(document) + if (!issuingAuthorityIdSet.contains(document.issuingAuthorityIdentifier)) { + issuingAuthorityIdSet.add(document.issuingAuthorityIdentifier) + startListeningForNotifications(document.issuingAuthorityIdentifier) } + updateCredman() + } - DocumentStore.EventType.DOCUMENT_DELETED -> { - removeDocument(document) - updateCredman() - } + DocumentStore.EventType.DOCUMENT_DELETED -> { + removeDocument(document) + updateCredman() + } - DocumentStore.EventType.DOCUMENT_UPDATED -> { - // Store the name rather than instance to handle the case that the - // document may have been deleted between now and the time it's - // processed below... - batchedUpdateFlow.emit(document.name) - } + DocumentStore.EventType.DOCUMENT_UPDATED -> { + // Store the name rather than instance to handle the case that the + // document may have been deleted between now and the time it's + // processed below... + batchedUpdateFlow.emit(document.name) } } - .launchIn(this) - - batchedUpdateFlow.timedChunk(batchDuration) - .onEach { - it.distinct().forEach { - documentStore.lookupDocument(it)?.let { - Logger.i(TAG, "Processing delayed update event ${it.name}") - updateDocument(it) - updateCredman() - } + } + .launchIn(this) + + batchedUpdateFlow.timedChunk(batchDuration) + .onEach { + it.distinct().forEach { + documentStore.lookupDocument(it)?.let { + Logger.i(TAG, "Processing delayed update event ${it.name}") + updateDocument(it) + updateCredman() } } - .launchIn(this) - } + } + .launchIn(this) } // If the LSKF is removed, make sure we delete all credentials with invalidated keys... @@ -577,21 +570,25 @@ class DocumentModel( TAG, "screenLockIsSetup changed to " + "${settingsModel.screenLockIsSetup.value}" ) - for (documentId in documentStore.listDocuments()) { - documentStore.lookupDocument(documentId)?.let { document -> - document.deleteInvalidatedCredentials() + CoroutineScope(Dispatchers.IO).launch { + for (documentId in documentStore.listDocuments()) { + documentStore.lookupDocument(documentId)?.let { document -> + document.deleteInvalidatedCredentials() + } } } } - // Initial data population and export to Credman - // - for (documentId in documentStore.listDocuments()) { - documentStore.lookupDocument(documentId)?.let { - addDocument(it) + CoroutineScope(Dispatchers.IO).launch { + // Initial data population and export to Credman + // + for (documentId in documentStore.listDocuments()) { + documentStore.lookupDocument(documentId)?.let { + addDocument(it) + } } + updateCredman() } - updateCredman() } // This processes events from an Issuing Authority which is a stream of events @@ -638,7 +635,7 @@ class DocumentModel( * Called periodically by [WalletApplication.SyncDocumentWithIssuerWorker]. */ fun periodicSyncForAllDocuments() { - runBlocking { + CoroutineScope(Dispatchers.IO).launch { for (documentId in documentStore.listDocuments()) { documentStore.lookupDocument(documentId)?.let { document -> Logger.i(TAG, "Periodic sync for ${document.name}") @@ -752,9 +749,10 @@ class DocumentModel( credentialToReplace, credentialDomain, secureArea, - createKeySettings, docConf.mdocConfiguration!!.docType, - ) + ).apply { + generateKey(createKeySettings) + } } } @@ -771,9 +769,10 @@ class DocumentModel( credentialToReplace, credentialDomain, secureArea, - createKeySettings, configuration.vct, - ) + ).apply { + generateKey(createKeySettings) + } } } } @@ -795,7 +794,7 @@ class DocumentModel( ) } else { document.pendingCredentials.find { - (it as SecureAreaBoundCredential).attestation + (it as SecureAreaBoundCredential).getAttestation() .publicKey.equals(credentialData.secureAreaBoundKey) } } @@ -819,12 +818,12 @@ class DocumentModel( document: Document, credentialDomain: String, credentialFormat: CredentialFormat, - createCredential: (( + createCredential: suspend ( credentialToReplace: Credential?, credentialDomain: String, secureArea: SecureArea, createKeySettings: CreateKeySettings, - ) -> Credential) + ) -> Credential ) { val numCreds = document.issuingAuthorityConfiguration.numberOfCredentialsToRequest ?: 3 val minValidTimeMillis = document.issuingAuthorityConfiguration.minCredentialValidityMillis ?: (30 * 24 * 3600L) @@ -888,7 +887,7 @@ class DocumentModel( for (pendingCredential in document.pendingCredentials) { credentialRequests.add( CredentialRequest( - (pendingCredential as SecureAreaBoundCredential).attestation + (pendingCredential as SecureAreaBoundCredential).getAttestation() ) ) } @@ -1041,7 +1040,7 @@ suspend fun signWithUnlock( } remainingPassphraseAttempts-- - val constraints = secureArea.passphraseConstraints + val constraints = secureArea.getPassphraseConstraints() val title = if (constraints.requireNumerical) activity.resources.getString(R.string.passphrase_prompt_csa_pin_title) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/NfcEngagementHandler.kt b/wallet/src/main/java/com/android/identity_credential/wallet/NfcEngagementHandler.kt index a116d0b66..ae621118f 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/NfcEngagementHandler.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/NfcEngagementHandler.kt @@ -30,6 +30,10 @@ import com.android.identity.android.util.NfcUtil import com.android.identity.crypto.Crypto import com.android.identity.crypto.EcCurve import com.android.identity.util.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking class NfcEngagementHandler : HostApduService() { companion object { @@ -121,9 +125,12 @@ class NfcEngagementHandler : HostApduService() { if (engagementHelper == null) { val application: WalletApplication = application as WalletApplication - if (application.documentStore.listDocuments().size > 0 - && !PresentationActivity.isPresentationActive()) { - + // TODO: how to avoid this? Need to create non-blocking way to determine if there + // are any documents in the documentStore + val hasDocuments = runBlocking { + application.documentStore.listDocuments().isNotEmpty() + } + if (hasDocuments && !PresentationActivity.isPresentationActive()) { PresentationActivity.engagementDetected(application.applicationContext) val walletApplication = application as WalletApplication diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt index 9becb3e48..6ee1aab9e 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/PresentationActivity.kt @@ -499,7 +499,9 @@ class PresentationActivity : FragmentActivity() { * @return a matching [MdocCredential] from either on-screen Document or [DocumentStore] * or null if there are no matching MdocCredential */ - private fun findMdocCredentialForRequest(docRequest: DeviceRequestParser.DocRequest): MdocCredential? { + private suspend fun findMdocCredentialForRequest( + docRequest: DeviceRequestParser.DocRequest + ): MdocCredential? { val now = Clock.System.now() // prefer the document that is on-screen if possible diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt index 613a78bf8..52faa97ee 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ProvisioningViewModel.kt @@ -131,15 +131,13 @@ class ProvisioningViewModel : ViewModel() { val documentIdentifier = issuerConfiguration.identifier + "_" + issuerDocumentIdentifier - document = documentStore.createDocument(documentIdentifier) val pendingDocumentConfiguration = issuerConfiguration.pendingDocumentInformation - - document!!.let { - it.issuingAuthorityIdentifier = issuerConfiguration.identifier - it.documentIdentifier = issuerDocumentIdentifier - it.documentConfiguration = pendingDocumentConfiguration - it.issuingAuthorityConfiguration = issuerConfiguration - it.refreshState(walletServerProvider) + document = documentStore.createDocument(documentIdentifier).apply { + this.issuingAuthorityIdentifier = issuerConfiguration.identifier + this.documentIdentifier = issuerDocumentIdentifier + this.documentConfiguration = pendingDocumentConfiguration + this.issuingAuthorityConfiguration = issuerConfiguration + this.refreshState(walletServerProvider) } proofingFlow = issuer.proof(issuerDocumentIdentifier) @@ -184,10 +182,13 @@ class ProvisioningViewModel : ViewModel() { parts[1], parts[2], null, null) } - fun evidenceCollectionFailed( - error: Throwable ) { - if (document != null) { - documentStore.deleteDocument(document!!.name) + suspend fun evidenceCollectionFailed( + error: Throwable + ) { + val nameToDelete = document?.name + if (nameToDelete != null) { + document = null + documentStore.deleteDocument(nameToDelete) } Logger.w(TAG, "Error collecting evidence", error) this.error = error @@ -216,8 +217,10 @@ class ProvisioningViewModel : ViewModel() { state.value = State.EVIDENCE_REQUESTS_READY } } catch (e: Throwable) { - if (document != null) { - documentStore.deleteDocument(document!!.name) + val nameToDelete = document?.name + if (nameToDelete != null) { + document = null + documentStore.deleteDocument(nameToDelete) } Logger.w(TAG, "Error submitting evidence", e) e.printStackTrace() @@ -274,12 +277,12 @@ class ProvisioningViewModel : ViewModel() { } } - fun moveToNextEvidenceRequest(): Boolean { + suspend fun moveToNextEvidenceRequest(): Boolean { currentEvidenceRequestIndex++ return selectViableEvidenceRequest() } - private fun selectViableEvidenceRequest(): Boolean { + private suspend fun selectViableEvidenceRequest(): Boolean { val evidenceRequests = this.evidenceRequests!! if (currentEvidenceRequestIndex >= evidenceRequests.size) { return false @@ -306,7 +309,7 @@ class ProvisioningViewModel : ViewModel() { return true } - private fun selectCredential(request: String): Credential? { + private suspend fun selectCredential(request: String): Credential? { val parts = request.split('.') val openid4vpRequest = JSONObject(String(parts[1].fromBase64Url())) @@ -334,7 +337,7 @@ class ProvisioningViewModel : ViewModel() { return document?.findCredential(WalletApplication.CREDENTIAL_DOMAIN_MDOC, Clock.System.now()) } - private fun firstMatchingDocument( + private suspend fun firstMatchingDocument( credentialFormat: CredentialFormat, docType: String ): Document? { @@ -352,7 +355,7 @@ class ProvisioningViewModel : ViewModel() { return docId?.let { documentStore.lookupDocument(it) } } - private fun canDocumentSatisfyRequest( + private suspend fun canDocumentSatisfyRequest( credentialId: String, credentialFormat: CredentialFormat, docType: String diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt index a7454e575..2fdad1e92 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/WalletApplication.kt @@ -27,7 +27,6 @@ import androidx.work.Worker import androidx.work.WorkerParameters import com.android.identity.android.securearea.AndroidKeystoreSecureArea import com.android.identity.android.securearea.cloud.CloudSecureArea -import com.android.identity.android.storage.AndroidStorageEngine import com.android.identity.credential.CredentialFactory import com.android.identity.document.Document import com.android.identity.document.DocumentStore @@ -47,17 +46,19 @@ import com.android.identity.mdoc.credential.MdocCredential import com.android.identity.mdoc.vical.SignedVical import com.android.identity.sdjwt.credential.KeyBoundSdJwtVcCredential import com.android.identity.sdjwt.credential.KeylessSdJwtVcCredential +import com.android.identity.securearea.SecureAreaProvider import com.android.identity.securearea.SecureAreaRepository import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.android.AndroidStorage import com.android.identity.trustmanagement.TrustManager import com.android.identity.trustmanagement.TrustPoint import com.android.identity.util.Logger import com.android.identity_credential.wallet.logging.EventLogger import com.android.identity_credential.wallet.util.toByteArray import kotlinx.datetime.Clock -import kotlinx.io.files.Path import org.bouncycastle.jce.provider.BouncyCastleProvider +import java.io.File import java.net.URLDecoder import java.security.Security import java.util.concurrent.TimeUnit @@ -104,7 +105,7 @@ class WalletApplication : Application() { } // late instantiations - lateinit var storageEngine: StorageEngine + lateinit var storage: Storage lateinit var documentTypeRepository: DocumentTypeRepository lateinit var secureAreaRepository: SecureAreaRepository lateinit var credentialFactory: CredentialFactory @@ -113,8 +114,7 @@ class WalletApplication : Application() { lateinit var documentModel: DocumentModel lateinit var readerModel: ReaderModel lateinit var eventLogger: EventLogger - private lateinit var androidKeystoreSecureArea: AndroidKeystoreSecureArea - private lateinit var softwareSecureArea: SoftwareSecureArea + private lateinit var secureAreaProvider: SecureAreaProvider lateinit var walletServerProvider: WalletServerProvider override fun onCreate() { @@ -141,28 +141,24 @@ class WalletApplication : Application() { documentTypeRepository.addDocumentType(UtopiaMovieTicket.getDocumentType()) // init storage - val storageFile = Path(applicationContext.noBackupFilesDir.path, "identity.bin") - storageEngine = AndroidStorageEngine.Builder(applicationContext, storageFile).build() + val storageFile = File(applicationContext.noBackupFilesDir.path, "main.db") + storage = AndroidStorage(storageFile.absolutePath) // init EventLogger - eventLogger = EventLogger(storageEngine as AndroidStorageEngine) + eventLogger = EventLogger(storage) settingsModel = SettingsModel(this, sharedPreferences) // init AndroidKeyStoreSecureArea - androidKeystoreSecureArea = AndroidKeystoreSecureArea(applicationContext, storageEngine) - - // init SoftwareSecureArea - softwareSecureArea = SoftwareSecureArea(storageEngine) - // TODO: generate and set attestation keys + secureAreaProvider = SecureAreaProvider { + AndroidKeystoreSecureArea.create(applicationContext, storage) + } // init SecureAreaRepository - secureAreaRepository = SecureAreaRepository() - secureAreaRepository.addImplementation(androidKeystoreSecureArea) - secureAreaRepository.addImplementation(softwareSecureArea) - secureAreaRepository.addImplementationFactory( - identifierPrefix = CloudSecureArea.IDENTIFIER_PREFIX, - factoryFunc = { identifier -> + secureAreaRepository = SecureAreaRepository.build { + add(SoftwareSecureArea.create(storage)) + add(secureAreaProvider.get()) + addFactory(CloudSecureArea.IDENTIFIER_PREFIX) { identifier -> val queryString = identifier.substring(CloudSecureArea.IDENTIFIER_PREFIX.length + 1) val params = queryString.split("&").map { val parts = it.split("=", ignoreCase = false, limit = 2) @@ -176,36 +172,34 @@ class WalletApplication : Application() { givenUrl } Logger.i(TAG, "Creating CSA with url $cloudSecureAreaUrl for $identifier") - val cloudSecureArea = CloudSecureArea( - applicationContext, - storageEngine, + CloudSecureArea.create( + storage, identifier, cloudSecureAreaUrl ) - return@addImplementationFactory cloudSecureArea } - ) + } // init credentialFactory credentialFactory = CredentialFactory() credentialFactory.addCredentialImplementation(MdocCredential::class) { - document, dataItem -> MdocCredential(document, dataItem) + document, dataItem -> MdocCredential(document).apply { deserialize(dataItem) } } credentialFactory.addCredentialImplementation(KeyBoundSdJwtVcCredential::class) { - document, dataItem -> KeyBoundSdJwtVcCredential(document, dataItem) + document, dataItem -> KeyBoundSdJwtVcCredential(document).apply { deserialize(dataItem) } } credentialFactory.addCredentialImplementation(KeylessSdJwtVcCredential::class) { - document, dataItem -> KeylessSdJwtVcCredential(document, dataItem) + document, dataItem -> KeylessSdJwtVcCredential(document).apply { deserialize(dataItem) } } // init documentStore - documentStore = DocumentStore(storageEngine, secureAreaRepository, credentialFactory) + documentStore = DocumentStore(storage, secureAreaRepository, credentialFactory) // init Wallet Server walletServerProvider = WalletServerProvider( this, - this. - androidKeystoreSecureArea, + storage, + secureAreaProvider, settingsModel ) { getWalletApplicationInformation() diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt index 2642c212d..c04ed93cd 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanPresentationActivity.kt @@ -125,29 +125,30 @@ class CredmanPresentationActivity : FragmentActivity() { requestedData.getOrPut(namespace) { mutableListOf() } .add(Pair(name, intentToRetain)) } - val mdocCredential = getMdocCredentialForCredentialId(credentialId) - val claims = MdocUtil.generateClaims( - docType, - requestedData, - walletApp.documentTypeRepository, - mdocCredential - ) - // Generate the Session Transcript - val encodedSessionTranscript = if (callingOrigin == null) { - CredmanUtil.generateAndroidSessionTranscript( - nonce, - callingPackageName, - Crypto.digest(Algorithm.SHA256, readerPublicKey.asUncompressedPointEncoding) - ) - } else { - CredmanUtil.generateBrowserSessionTranscript( - nonce, - callingOrigin, - Crypto.digest(Algorithm.SHA256, readerPublicKey.asUncompressedPointEncoding) - ) - } lifecycleScope.launch { + val mdocCredential = getMdocCredentialForCredentialId(credentialId) + val claims = MdocUtil.generateClaims( + docType, + requestedData, + walletApp.documentTypeRepository, + mdocCredential + ) + + // Generate the Session Transcript + val encodedSessionTranscript = if (callingOrigin == null) { + CredmanUtil.generateAndroidSessionTranscript( + nonce, + callingPackageName, + Crypto.digest(Algorithm.SHA256, readerPublicKey.asUncompressedPointEncoding) + ) + } else { + CredmanUtil.generateBrowserSessionTranscript( + nonce, + callingOrigin, + Crypto.digest(Algorithm.SHA256, readerPublicKey.asUncompressedPointEncoding) + ) + } val deviceResponse = showPresentmentFlowAndGetDeviceResponse( mdocCredential, claims, @@ -214,39 +215,39 @@ class CredmanPresentationActivity : FragmentActivity() { encodedSessionTranscript ).parse().docRequests[0] - val mdocCredential = getMdocCredentialForCredentialId(credentialId) - val claims = docRequest.toMdocRequest( - documentTypeRepository = walletApp.documentTypeRepository, - mdocCredential = mdocCredential - ).claims - - val encryptionInfo = Cbor.decode(encryptionInfoBase64.fromBase64Url()) - if (encryptionInfo.asArray.get(0).asTstr != "ARFEncryptionv2") { - throw IllegalArgumentException("Malformed EncryptionInfo") - } - val nonce = encryptionInfo.asArray.get(1).asMap.get(Tstr("nonce"))!!.asBstr - val readerPublicKey = encryptionInfo.asArray.get(1).asMap.get(Tstr - ("readerPublicKey"))!!.asCoseKey.ecPublicKey - - // See if we recognize the reader/verifier - var trustPoint: TrustPoint? = null - if (docRequest.readerAuthenticated) { - val result = walletApp.readerTrustManager.verify( - docRequest.readerCertificateChain!!.certificates, - ) - if (result.isTrusted && result.trustPoints.isNotEmpty()) { - trustPoint = result.trustPoints.first() - } else if (result.error != null) { - Logger.w( - TAG, - "Error finding TrustPoint for reader auth", - result.error!! + lifecycleScope.launch { + val mdocCredential = getMdocCredentialForCredentialId(credentialId) + val claims = docRequest.toMdocRequest( + documentTypeRepository = walletApp.documentTypeRepository, + mdocCredential = mdocCredential + ).claims + + val encryptionInfo = Cbor.decode(encryptionInfoBase64.fromBase64Url()) + if (encryptionInfo.asArray.get(0).asTstr != "ARFEncryptionv2") { + throw IllegalArgumentException("Malformed EncryptionInfo") + } + val nonce = encryptionInfo.asArray.get(1).asMap.get(Tstr("nonce"))!!.asBstr + val readerPublicKey = encryptionInfo.asArray.get(1).asMap.get(Tstr + ("readerPublicKey"))!!.asCoseKey.ecPublicKey + + // See if we recognize the reader/verifier + var trustPoint: TrustPoint? = null + if (docRequest.readerAuthenticated) { + val result = walletApp.readerTrustManager.verify( + docRequest.readerCertificateChain!!.certificates, ) + if (result.isTrusted && result.trustPoints.isNotEmpty()) { + trustPoint = result.trustPoints.first() + } else if (result.error != null) { + Logger.w( + TAG, + "Error finding TrustPoint for reader auth", + result.error!! + ) + } } - } - Logger.i(TAG, "TrustPoint: $trustPoint") + Logger.i(TAG, "TrustPoint: $trustPoint") - lifecycleScope.launch { val deviceResponse = showPresentmentFlowAndGetDeviceResponse( mdocCredential, claims, @@ -326,29 +327,30 @@ class CredmanPresentationActivity : FragmentActivity() { requestedData.getOrPut(namespace) { mutableListOf() } .add(Pair(name, intentToRetain)) } - val mdocCredential = getMdocCredentialForCredentialId(credentialId) - val claims = MdocUtil.generateClaims( - docType, - requestedData, - walletApp.documentTypeRepository, - mdocCredential - ) - - // Generate the Session Transcript - val encodedSessionTranscript = if (callingOrigin == null) { - CredmanUtil.generateAndroidSessionTranscript( - nonce, - callingPackageName, - Crypto.digest(Algorithm.SHA256, clientID.toByteArray()) - ) - } else { - CredmanUtil.generateBrowserSessionTranscript( - nonce, - callingOrigin, - Crypto.digest(Algorithm.SHA256, clientID.toByteArray()) - ) - } lifecycleScope.launch { + val mdocCredential = getMdocCredentialForCredentialId(credentialId) + val claims = MdocUtil.generateClaims( + docType, + requestedData, + walletApp.documentTypeRepository, + mdocCredential + ) + + // Generate the Session Transcript + val encodedSessionTranscript = if (callingOrigin == null) { + CredmanUtil.generateAndroidSessionTranscript( + nonce, + callingPackageName, + Crypto.digest(Algorithm.SHA256, clientID.toByteArray()) + ) + } else { + CredmanUtil.generateBrowserSessionTranscript( + nonce, + callingOrigin, + Crypto.digest(Algorithm.SHA256, clientID.toByteArray()) + ) + } + val deviceResponse = showPresentmentFlowAndGetDeviceResponse( mdocCredential, claims, @@ -437,8 +439,8 @@ class CredmanPresentationActivity : FragmentActivity() { * @param credentialId the index of the credential in the document store. * @return the [MdocCredential] object. */ - private fun getMdocCredentialForCredentialId(credentialId: Int): MdocCredential { - val documentName = walletApp.documentStore.listDocuments().get(credentialId) + private suspend fun getMdocCredentialForCredentialId(credentialId: Int): MdocCredential { + val documentName = walletApp.documentStore.listDocuments()[credentialId] val document = walletApp.documentStore.lookupDocument(documentName) return document!!.findCredential( diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanRegistry.kt b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanRegistry.kt index d11300c34..decb72597 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanRegistry.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/credman/CredmanRegistry.kt @@ -34,7 +34,7 @@ object CredmanRegistry { return dataElementName } - fun registerCredentials( + suspend fun registerCredentials( context: Context, documentStore: DocumentStore, documentTypeRepository: DocumentTypeRepository @@ -43,10 +43,7 @@ object CredmanRegistry { val entries = mutableListOf() for (documentId in documentStore.listDocuments()) { - val document = documentStore.lookupDocument(documentId) - if (document == null) { - continue - } + val document = documentStore.lookupDocument(documentId) ?: continue val docConf = document.documentConfiguration if (docConf.mdocConfiguration == null) { return diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/logging/DocumentUpdateCheckEvent.kt b/wallet/src/main/java/com/android/identity_credential/wallet/logging/DocumentUpdateCheckEvent.kt index 3f02cbf88..2fac984e7 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/logging/DocumentUpdateCheckEvent.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/logging/DocumentUpdateCheckEvent.kt @@ -3,7 +3,8 @@ package com.android.identity_credential.wallet.logging import kotlinx.datetime.Clock import kotlinx.datetime.Instant -data class DocumentUpdateCheckEvent( - override var timestamp: Instant = Clock.System.now(), +class DocumentUpdateCheckEvent( + timestamp: Instant = Clock.System.now(), var documentId: String = "", - ) : Event(timestamp) \ No newline at end of file + id: String = "" +) : Event(timestamp, id) \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/logging/Event.kt b/wallet/src/main/java/com/android/identity_credential/wallet/logging/Event.kt index f4b0b25ff..b0031ca41 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/logging/Event.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/logging/Event.kt @@ -6,6 +6,7 @@ import kotlinx.datetime.Instant @CborSerializable sealed class Event( open val timestamp: Instant, + var id: String = "" // filled by EventLogger ) { companion object } \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/logging/EventLogger.kt b/wallet/src/main/java/com/android/identity_credential/wallet/logging/EventLogger.kt index 12457892b..1089d1533 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/logging/EventLogger.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/logging/EventLogger.kt @@ -1,9 +1,11 @@ package com.android.identity_credential.wallet.logging import com.android.identity.cbor.annotation.CborSerializable -import com.android.identity.storage.StorageEngine +import com.android.identity.storage.Storage +import com.android.identity.storage.StorageTableSpec import com.android.identity.util.Logger import kotlinx.datetime.Clock +import kotlinx.io.bytestring.ByteString /** * Event Logging facility. @@ -15,9 +17,13 @@ private const val TAG = "EventLogger" private var eventLoggingEnabled = false -class EventLogger(private val storageEngine: StorageEngine) { +class EventLogger(private val storage: Storage) { companion object { - const val STORAGE_KEY_PREFIX = "event_log_" + internal val eventTableSpec = StorageTableSpec( + name = "Events", + supportExpiration = false, + supportPartitions = false + ) } fun startLoggingEvents() { @@ -66,7 +72,7 @@ class EventLogger(private val storageEngine: StorageEngine) { val shareType: ShareType ) - fun addMDocPresentationEntry( + suspend fun addMDocPresentationEntry( documentId: String, sessionTranscript: ByteArray, deviceRequestCbor: ByteArray, @@ -79,7 +85,7 @@ class EventLogger(private val storageEngine: StorageEngine) { return // Exit immediately if logging is disabled } - val uniqueId = Clock.System.now() + val timestamp = Clock.System.now() val requesterInfo = RequesterInfo( requester = requesterType, shareType = shareType @@ -87,7 +93,7 @@ class EventLogger(private val storageEngine: StorageEngine) { val serializedEntry: ByteArray val event = MdocPresentationEvent( - timestamp = uniqueId, + timestamp = timestamp, documentId = documentId, sessionTranscript = sessionTranscript, deviceRequestCbor = deviceRequestCbor, @@ -96,40 +102,39 @@ class EventLogger(private val storageEngine: StorageEngine) { ) serializedEntry = event.toCbor() - val key = STORAGE_KEY_PREFIX + uniqueId - storageEngine.put(key, serializedEntry) + storage.getTable(eventTableSpec).insert(key = null, ByteString(serializedEntry)) } - fun getEntries(documentId: String): List { + suspend fun getEntries(documentId: String): List { val entries = mutableListOf() - for (key in storageEngine.enumerate()) { - if (key.startsWith(STORAGE_KEY_PREFIX)) { - val value = storageEngine[key] - if (value != null) { - try { - val event = Event.fromCbor(value) - when { - event is MdocPresentationEvent && event.documentId == documentId -> entries.add(event) - event is DocumentUpdateCheckEvent && event.documentId == documentId -> entries.add(event) - } - } catch(e: IllegalStateException) { - Logger.w(TAG, "Failed to deserialize event for key: $key", e) + val table = storage.getTable(eventTableSpec) + for (key in table.enumerate()) { + val value = table.get(key) + if (value != null) { + try { + val event = Event.fromCbor(value.toByteArray()) + event.id = key + when { + event is MdocPresentationEvent && event.documentId == documentId -> entries.add(event) + event is DocumentUpdateCheckEvent && event.documentId == documentId -> entries.add(event) } + } catch(e: IllegalStateException) { + Logger.w(TAG, "Failed to deserialize event for key: $key", e) } } } - return entries + return entries.sortedBy { event -> event.timestamp } } - fun deleteEntries(entries: List) { + suspend fun deleteEntries(entries: List) { + val table = storage.getTable(eventTableSpec) for (entry in entries) { - val key = STORAGE_KEY_PREFIX + entry.timestamp - storageEngine.delete(key) - Logger.i(TAG, "Deleted entry with key: $key") + table.delete(entry.id) + Logger.i(TAG, "Deleted entry with timestamp: ${entry.timestamp}") } } - fun deleteEntriesForDocument(documentId: String) { + suspend fun deleteEntriesForDocument(documentId: String) { val entriesToDelete = getEntries(documentId) deleteEntries(entriesToDelete) Logger.i(TAG, "Deleted all entries for documentId: $documentId") diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/logging/MdocPresentationEvent.kt b/wallet/src/main/java/com/android/identity_credential/wallet/logging/MdocPresentationEvent.kt index 48bb09963..38e4dc59f 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/logging/MdocPresentationEvent.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/logging/MdocPresentationEvent.kt @@ -3,13 +3,14 @@ package com.android.identity_credential.wallet.logging import kotlinx.datetime.Clock import kotlinx.datetime.Instant -data class MdocPresentationEvent ( - override var timestamp: Instant = Clock.System.now(), +class MdocPresentationEvent ( + timestamp: Instant = Clock.System.now(), var documentId: String = "", var sessionTranscript: ByteArray, var deviceRequestCbor: ByteArray, var deviceResponseCbor: ByteArray, val requesterInfo: EventLogger.RequesterInfo = EventLogger.RequesterInfo( requester = EventLogger.Requester.Anonymous(), - shareType = EventLogger.ShareType.UNKNOWN) -) : Event(timestamp) \ No newline at end of file + shareType = EventLogger.ShareType.UNKNOWN), + id: String = "" +) : Event(timestamp, id) \ No newline at end of file diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt index 6a72f5128..3d9083cd5 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/OpenID4VPPresentationActivity.kt @@ -313,13 +313,12 @@ class OpenID4VPPresentationActivity : FragmentActivity() { } - private fun firstMatchingDocument( + private suspend fun firstMatchingDocument( credentialFormat: CredentialFormat, docType: String ): Document? { val settingsModel = walletApp.settingsModel val documentStore = walletApp.documentStore - // prefer the credential which is on-screen if possible val credentialIdFromPager: String? = settingsModel.focusedCardId.value if (credentialIdFromPager != null @@ -337,7 +336,7 @@ class OpenID4VPPresentationActivity : FragmentActivity() { return docId?.let { documentStore.lookupDocument(it) } } - private fun canDocumentSatisfyRequest( + private suspend fun canDocumentSatisfyRequest( credentialId: String, credentialFormat: CredentialFormat, docType: String @@ -505,16 +504,23 @@ class OpenID4VPPresentationActivity : FragmentActivity() { } } + // TODO: is this line needed? val documentRequest = formatAsDocumentRequest(inputDescriptorObj) - val document = firstMatchingDocument(credentialFormat, docType) - ?: run { throw NoMatchingDocumentException("No matching credentials in wallet for " + - "docType $docType and credentialFormat $credentialFormat") } - // begin collecting and creating data needed for the response val secureRandom = Random.Default val bytes = ByteArray(16) secureRandom.nextBytes(bytes) val generatedNonce = Base64.UrlSafe.encode(bytes) + + val document = firstMatchingDocument(credentialFormat, docType) + ?: run { + throw NoMatchingDocumentException( + "No matching credentials in wallet for " + + "docType $docType and credentialFormat $credentialFormat" + ) + } + + // begin collecting and creating data needed for the response val sessionTranscript = createSessionTranscript( clientId = authorizationRequest.clientId, responseUri = authorizationRequest.responseUri, @@ -531,7 +537,8 @@ class OpenID4VPPresentationActivity : FragmentActivity() { document, credentialFormat, inputDescriptorObj, - now) + now + ) var trustPoint: TrustPoint? = null if (authorizationRequest.certificateChain != null) { @@ -549,11 +556,20 @@ class OpenID4VPPresentationActivity : FragmentActivity() { } } - val vpTokenByteArray = generateVpToken(consentFields, credentialToUse, trustPoint, authorizationRequest, sessionTranscript) - Logger.i(TAG, "Setting vp_token: ${vpTokenByteArray.decodeToString()}") + val vpTokenByteArray = generateVpToken( + consentFields, + credentialToUse, + trustPoint, + authorizationRequest, + sessionTranscript + ) val vpToken = if (credentialToUse is MdocCredential) { Base64.UrlSafe.encode(vpTokenByteArray).replace("=", "") - } else vpTokenByteArray.decodeToString() + } else { + vpTokenByteArray.decodeToString() + } + + Logger.i(TAG, "Setting vp_token: $vpToken") val claimSet = JWTClaimsSet.parse(Json.encodeToString(buildJsonObject { // put("id_token", idToken) // depends on response type, only supporting vp_token for now put("state", authorizationRequest.state) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt index df6fd00f7..a967d293d 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/PresentmentFlow.kt @@ -49,7 +49,7 @@ private suspend fun showPresentmentFlowImpl( trustPoint: TrustPoint?, document: ConsentDocument, credential: Credential, - signAndGenerate: (KeyUnlockData?) -> ByteArray + signAndGenerate: suspend (KeyUnlockData?) -> ByteArray ): ByteArray { // always show the Consent Prompt first showConsentPrompt( @@ -160,7 +160,7 @@ private suspend fun showPresentmentFlowImpl( } remainingPassphraseAttempts-- - val constraints = (secureAreaBoundCredential.secureArea as CloudSecureArea).passphraseConstraints + val constraints = (secureAreaBoundCredential.secureArea as CloudSecureArea).getPassphraseConstraints() val title = if (constraints.requireNumerical) activity.resources.getString(R.string.passphrase_prompt_csa_pin_title) @@ -279,7 +279,7 @@ suspend fun showSdJwtPresentmentFlow( } } -private fun mdocSignAndGenerate( +private suspend fun mdocSignAndGenerate( claims: List, credential: SecureAreaBoundCredential, encodedSessionTranscript: ByteArray, diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/TransferHelper.kt b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/TransferHelper.kt index 342e3a8a0..8a0917ca7 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/presentation/TransferHelper.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/presentation/TransferHelper.kt @@ -86,7 +86,7 @@ class TransferHelper( * @return a PresentationRequestData object containing data used to finish processing the request * and generate response bytes, or null if no credential id could be found. */ - fun startProcessingRequest(deviceRequest: ByteArray): PresentationRequestData? { + suspend fun startProcessingRequest(deviceRequest: ByteArray): PresentationRequestData? { // TODO: we currently only look at the first docRequest ... in the future need to process // all of them sequentially. @@ -212,7 +212,7 @@ class TransferHelper( mergedIssuerNamespaces: Map>, credential: MdocCredential, keyUnlockData: KeyUnlockData? - ) = suspendCancellableCoroutine { continuation -> + ): MdocCredential? { var result: MdocCredential? try { @@ -246,7 +246,7 @@ class TransferHelper( result = credential } - continuation.resume(result) + return result } /** @@ -270,7 +270,7 @@ class TransferHelper( * @param docRequest the docRequest, including the requested DocType. * @return credential identifier if found, otherwise null. */ - private fun findFirstdocumentSatisfyingRequest( + private suspend fun findFirstdocumentSatisfyingRequest( settingsModel: SettingsModel, credentialFormat: CredentialFormat, docRequest: DeviceRequestParser.DocRequest, @@ -297,7 +297,7 @@ class TransferHelper( * @param docRequest the DocRequest, including the DocType * @return whether the specified credential id can satisfy the request */ - private fun canDocumentSatisfyRequest( + private suspend fun canDocumentSatisfyRequest( credentialId: String, credentialFormat: CredentialFormat, docRequest: DeviceRequestParser.DocRequest diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentInfoScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentInfoScreen.kt index bed85a978..b8b75b351 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentInfoScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/DocumentInfoScreen.kt @@ -168,7 +168,9 @@ fun DocumentInfoScreen( Button( onClick = { showDeleteConfirmationDialog = false - documentModel.deleteCard(documentInfo) + coroutineScope.launch { + documentModel.deleteCard(documentInfo) + } onNavigate(WalletDestination.PopBackStack.route) }) { Text(stringResource(R.string.document_info_screen_confirm_deletion_confirm_button)) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/EventLogScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/EventLogScreen.kt index a9e1579e1..0fc94e064 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/EventLogScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/document/EventLogScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -32,6 +33,7 @@ import com.android.identity_credential.wallet.logging.EventLogger import com.android.identity_credential.wallet.navigation.WalletDestination import com.android.identity_credential.wallet.ui.KeyValuePairText import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton +import kotlinx.coroutines.launch import kotlinx.datetime.Instant import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime @@ -76,9 +78,13 @@ fun EventLogScreen( documentModel: DocumentModel, navController: NavHostController ) { + val coroutineScope = rememberCoroutineScope() + // TODO: this needs a ViewModel layer val eventInfos = remember { mutableStateListOf().apply { - addAll(documentModel.getEventInfos(documentId)) + coroutineScope.launch { + addAll(documentModel.getEventInfos(documentId)) + } } } @@ -147,7 +153,15 @@ fun EventDetailsScreen( timestamp: String ) { // Retrieve the event based on documentId and timestamp - val eventInfos = remember { documentModel.getEventInfos(documentId) } + val coroutineScope = rememberCoroutineScope() + // TODO: this needs a ViewModel layer + val eventInfos = remember { + mutableStateListOf().apply { + coroutineScope.launch { + addAll(documentModel.getEventInfos(documentId)) + } + } + } val eventInfo = eventInfos.find { it.timestamp == timestamp } ScreenWithAppBarAndBackButton( diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt index 3fe4ebce9..7af8114c8 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/EvidenceRequest.kt @@ -497,23 +497,34 @@ fun EvidenceRequestSetupCloudSecureAreaView( onAccept: () -> Unit, onError: (error: Throwable) -> Unit ) { + val cloudSecureAreaState = remember { mutableStateOf(null) } println("secureAreaRepository: $secureAreaRepository") - val cloudSecureArea = secureAreaRepository.getImplementation( - evidenceRequest.cloudSecureAreaIdentifier - ) - if (cloudSecureArea == null) { - throw IllegalStateException("Cannot create Secure Area with id ${evidenceRequest.cloudSecureAreaIdentifier}") - } - if (cloudSecureArea !is CloudSecureArea) { - throw IllegalStateException("Expected type CloudSecureArea, got $cloudSecureArea") + val scope = rememberCoroutineScope() + SideEffect { + scope.launch { + val cloudSecureArea = secureAreaRepository.getImplementation( + evidenceRequest.cloudSecureAreaIdentifier + ) + if (cloudSecureArea == null) { + throw IllegalStateException("Cannot create Secure Area with id ${evidenceRequest.cloudSecureAreaIdentifier}") + } + if (cloudSecureArea !is CloudSecureArea) { + throw IllegalStateException("Expected type CloudSecureArea, got $cloudSecureArea") + } + if (cloudSecureArea.isRegistered) { + println("CSA already registered") + onAccept() + } else { + cloudSecureAreaState.value = cloudSecureArea + } + } } - if (cloudSecureArea.isRegistered) { - println("CSA already registered") - onAccept() + + if (cloudSecureAreaState.value == null) { + Text("TODO: Waiting....") return } - val scope = rememberCoroutineScope() var chosenPassphrase by remember { mutableStateOf("") } var verifiedPassphrase by remember { mutableStateOf("") } var showMatchErrorText by remember { mutableStateOf(false) } @@ -610,7 +621,7 @@ fun EvidenceRequestSetupCloudSecureAreaView( SideEffect { scope.launch { try { - cloudSecureArea.register( + cloudSecureAreaState.value!!.register( chosenPassphrase, evidenceRequest.passphraseConstraints, ) { true } @@ -1699,6 +1710,7 @@ fun EvidenceRequestOpenid4Vp( ) { val cx = LocalContext.current val credential = provisioningViewModel.selectedOpenid4VpCredential.value!! + val coroutineScope = rememberCoroutineScope() Column { Row( modifier = Modifier.fillMaxWidth(), @@ -1744,7 +1756,11 @@ fun EvidenceRequestOpenid4Vp( } Button( modifier = Modifier.padding(8.dp), - onClick = {provisioningViewModel.moveToNextEvidenceRequest()} + onClick = { + coroutineScope.launch { + provisioningViewModel.moveToNextEvidenceRequest() + } + } ) { Text(text = evidenceRequest.cancelText ?: stringResource(id = R.string.presentation_evidence_cancel) diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt index 03f10f04b..fd84b3cb0 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/provisioncredential/ProvisionCredentialScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -77,6 +78,8 @@ import com.nimbusds.jwt.EncryptedJWT import com.nimbusds.jwt.JWT import com.nimbusds.jwt.JWTClaimsSet import com.nimbusds.jwt.PlainJWT +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import org.json.JSONObject @@ -166,6 +169,7 @@ fun ProvisionDocumentScreen( } is EvidenceRequestSetupCloudSecureArea -> { + val coroutineScope = rememberCoroutineScope() EvidenceRequestSetupCloudSecureAreaView( context = context, secureAreaRepository = secureAreaRepository, @@ -177,9 +181,11 @@ fun ProvisionDocumentScreen( ) }, onError = { error -> - provisioningViewModel.evidenceCollectionFailed( - error = error - ) + coroutineScope.launch { + provisioningViewModel.evidenceCollectionFailed( + error = error + ) + } } ) } diff --git a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/settings/SettingsScreen.kt b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/settings/SettingsScreen.kt index de98a048f..5d0b4cb2c 100644 --- a/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/settings/SettingsScreen.kt +++ b/wallet/src/main/java/com/android/identity_credential/wallet/ui/destination/settings/SettingsScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -47,6 +48,7 @@ import com.android.identity_credential.wallet.ui.ScreenWithAppBarAndBackButton import com.android.identity_credential.wallet.ui.SettingSectionSubtitle import com.android.identity_credential.wallet.ui.SettingString import com.android.identity_credential.wallet.ui.SettingToggle +import kotlinx.coroutines.launch @Composable fun SettingsScreen( @@ -55,6 +57,7 @@ fun SettingsScreen( onNavigate: (String) -> Unit ) { var confirmServerChange by remember { mutableStateOf(null) } + val coroutineScope = rememberCoroutineScope() if (confirmServerChange != null) { AlertDialog( onDismissRequest = { confirmServerChange = null }, @@ -63,11 +66,13 @@ fun SettingsScreen( confirmButton = { Button( onClick = { - for (documentId in documentStore.listDocuments()) { - documentStore.deleteDocument(documentId) + coroutineScope.launch { + for (documentId in documentStore.listDocuments()) { + documentStore.deleteDocument(documentId) + } + confirmServerChange!!.onConfirm() + confirmServerChange = null } - confirmServerChange!!.onConfirm() - confirmServerChange = null }) { Text(stringResource(R.string.settings_screen_confirm_set_server_url_dialog_confirm)) } diff --git a/wallet/src/test/java/com/android/identity_credential/wallet/SelfSignedMdlTest.kt b/wallet/src/test/java/com/android/identity_credential/wallet/SelfSignedMdlTest.kt index 89449dfb4..0da9a4ff1 100644 --- a/wallet/src/test/java/com/android/identity_credential/wallet/SelfSignedMdlTest.kt +++ b/wallet/src/test/java/com/android/identity_credential/wallet/SelfSignedMdlTest.kt @@ -1,20 +1,10 @@ package com.android.identity_credential.wallet import com.android.identity.device.DeviceAssertion -import com.android.identity.issuance.RegistrationResponse -import com.android.identity.issuance.DocumentCondition -import com.android.identity.issuance.CredentialFormat -import com.android.identity.issuance.CredentialRequest -import com.android.identity.issuance.evidence.EvidenceRequestMessage -import com.android.identity.issuance.evidence.EvidenceRequestQuestionMultipleChoice -import com.android.identity.issuance.evidence.EvidenceRequestQuestionString import com.android.identity.issuance.evidence.EvidenceResponseMessage -import com.android.identity.issuance.evidence.EvidenceResponseQuestionMultipleChoice -import com.android.identity.issuance.evidence.EvidenceResponseQuestionString -import com.android.identity.issuance.evidence.EvidenceRequest import com.android.identity.securearea.CreateKeySettings import com.android.identity.securearea.software.SoftwareSecureArea -import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage import kotlinx.coroutines.test.runTest import kotlinx.io.bytestring.ByteString import org.bouncycastle.jce.provider.BouncyCastleProvider @@ -62,8 +52,8 @@ class SelfSignedMdlTest { @Test fun happyPath() = runTest { - val storageEngine = EphemeralStorageEngine() - val secureArea = SoftwareSecureArea(storageEngine) + val storage = EphemeralStorage() + val secureArea = SoftwareSecureArea.create(storage) val ia = TestIssuingAuthority() diff --git a/wallet/src/test/java/com/android/identity_credential/wallet/logging/EventLoggerTest.kt b/wallet/src/test/java/com/android/identity_credential/wallet/logging/EventLoggerTest.kt index 48227be14..63901708e 100644 --- a/wallet/src/test/java/com/android/identity_credential/wallet/logging/EventLoggerTest.kt +++ b/wallet/src/test/java/com/android/identity_credential/wallet/logging/EventLoggerTest.kt @@ -1,6 +1,8 @@ package com.android.identity_credential.wallet.logging import com.android.identity.storage.EphemeralStorageEngine +import com.android.identity.storage.ephemeral.EphemeralStorage +import kotlinx.coroutines.test.runTest import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before @@ -16,12 +18,12 @@ class EventLoggerTest( private val shareType: EventLogger.ShareType ) { - private lateinit var mockStorage: EphemeralStorageEngine + private lateinit var mockStorage: EphemeralStorage private lateinit var activityLogger: EventLogger @Before fun setUp() { - mockStorage = EphemeralStorageEngine() + mockStorage = EphemeralStorage() activityLogger = EventLogger(mockStorage) } @@ -38,7 +40,7 @@ class EventLoggerTest( } @Test - fun testAddAndDeleteEntries() { + fun testAddAndDeleteEntries() = runTest { activityLogger.startLoggingEvents() activityLogger.addMDocPresentationEntry( @@ -54,7 +56,8 @@ class EventLoggerTest( assertTrue(entries.isNotEmpty(), "Entries should not be empty after adding an event") activityLogger.deleteEntries(entries) - assertTrue(mockStorage.enumerate().isEmpty(), "Storage should be empty after deleting all entries") + val table = mockStorage.getTable(EventLogger.eventTableSpec) + assertTrue(table.enumerate().isEmpty(), "Storage should be empty after deleting all entries") } companion object {