diff --git a/identity-android/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java b/identity-android/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java new file mode 100644 index 000000000..bdd4a7272 --- /dev/null +++ b/identity-android/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java @@ -0,0 +1,552 @@ +/* + * 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 static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.content.Context; + +import com.android.identity.AndroidAttestationExtensionParser; +import com.android.identity.android.keystore.AndroidKeystore; +import com.android.identity.android.storage.AndroidStorageEngine; +import com.android.identity.credential.Credential; +import com.android.identity.credential.CredentialStore; +import com.android.identity.credential.NameSpacedData; +import com.android.identity.internal.Util; +import com.android.identity.keystore.KeystoreEngineRepository; +import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator; +import com.android.identity.mdoc.mso.StaticAuthDataGenerator; +import com.android.identity.mdoc.util.MdocUtil; +import com.android.identity.storage.StorageEngine; +import com.android.identity.util.Timestamp; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +import co.nstant.in.cbor.CborBuilder; +import co.nstant.in.cbor.CborEncoder; +import co.nstant.in.cbor.CborException; +import co.nstant.in.cbor.model.UnicodeString; +import co.nstant.in.cbor.model.UnsignedInteger; + +@SuppressWarnings("deprecation") +public class MigrateFromKeystoreICStoreTest { + private static final String TAG = ""; + final String DOC_TYPE = "com.example.credential_xyz"; + IdentityCredentialStore mICStore; + StorageEngine mStorageEngine; + + AndroidKeystore mKeystoreEngine; + + KeystoreEngineRepository mKeystoreEngineRepository; + + @Before + public void setup() { + Context context = androidx.test.InstrumentationRegistry.getTargetContext(); + File storageDir = new File(context.getDataDir(), "ic-testing"); + mStorageEngine = new AndroidStorageEngine.Builder(context, storageDir).build(); + + mKeystoreEngineRepository = new KeystoreEngineRepository(); + mKeystoreEngine = new AndroidKeystore(context, mStorageEngine); + mKeystoreEngineRepository.addImplementation(mKeystoreEngine); + + mICStore = Utility.getIdentityCredentialStore(context); + } + + // TODO: Replace with Assert.assertThrows() once we use a recent enough version of JUnit. + /** Asserts that the given {@code runnable} throws the given exception class, or a subclass. */ + private static void assertThrows( + Class expected, Runnable runnable) { + try { + runnable.run(); + fail("Expected " + expected + " was not thrown"); + } catch (RuntimeException e) { + Class actual = e.getClass(); + assertTrue("Unexpected Exception class: " + actual, + expected.isAssignableFrom(actual)); + } + } + + private static byte[] getExampleDrivingPrivilegesCbor() { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try { + new CborEncoder(baos).encode(new CborBuilder() + .addArray() + .addMap() + .put(new UnicodeString("vehicle_category_code"), new UnicodeString("TODO")) + .put(new UnicodeString("value"), new UnsignedInteger(42)) + .end() + .end() + .build()); + } catch (CborException e) { + fail(); + } + return baos.toByteArray(); + } + + static void createCredentialWithChallenge(IdentityCredentialStore store, + String credentialName, + byte[] challenge, + Map> namespacedData) + throws IdentityCredentialException { + WritableIdentityCredential wc = store.createCredential(credentialName, + "org.iso.18013-5.2019.mdl"); + + Collection certificateChain = + wc.getCredentialKeyCertificateChain(challenge); + + // Profile 0 (no authentication) + AccessControlProfile noAuthProfile = + new AccessControlProfile.Builder(new AccessControlProfileId(0)) + .setUserAuthenticationRequired(false) + .build(); + + byte[] drivingPrivileges = getExampleDrivingPrivilegesCbor(); + + Collection idsNoAuth = new ArrayList(); + idsNoAuth.add(new AccessControlProfileId(0)); + String mdlNs = "org.iso.18013-5.2019"; + PersonalizationData.Builder personalizationDataBuilder = + new PersonalizationData.Builder() + .addAccessControlProfile(noAuthProfile); + + for (String namespace : namespacedData.keySet()) { + for (String dataElemId : namespacedData.get(namespace).keySet()) { + personalizationDataBuilder.putEntry(namespace, dataElemId, idsNoAuth, namespacedData.get(namespace).get(dataElemId)); + } + } + + byte[] proofOfProvisioningSignature = wc.personalize(personalizationDataBuilder.build()); + byte[] proofOfProvisioning = + Util.coseSign1GetData(Util.cborDecode(proofOfProvisioningSignature)); + + assertTrue(Util.coseSign1CheckSignature( + Util.cborDecode(proofOfProvisioningSignature), + new byte[0], // Additional data + certificateChain.iterator().next().getPublicKey())); + + } + + static Collection createCredentialWithPersonalizationData( + IdentityCredentialStore store, + String credentialName, + byte[] challenge, + PersonalizationData personalizationData) throws IdentityCredentialException { + WritableIdentityCredential wc = null; + wc = store.createCredential(credentialName, "org.iso.18013-5.2019.mdl"); + + Collection certificateChain = + wc.getCredentialKeyCertificateChain(challenge); + + byte[] proofOfProvisioningSignature = wc.personalize(personalizationData); + byte[] proofOfProvisioning = + Util.coseSign1GetData(Util.cborDecode(proofOfProvisioningSignature)); + + assertTrue(Util.coseSign1CheckSignature( + Util.cborDecode(proofOfProvisioningSignature), + new byte[0], // Additional data + certificateChain.iterator().next().getPublicKey())); + + return certificateChain; + } + + private static KeyPair generateIssuingAuthorityKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1"); + kpg.initialize(ecSpec); + return kpg.generateKeyPair(); + } + + private static X509Certificate getSelfSignedIssuerAuthorityCertificate( + KeyPair issuerAuthorityKeyPair) throws Exception { + X500Name issuer = new X500Name("CN=State Of Utopia"); + X500Name subject = new X500Name("CN=State Of Utopia Issuing Authority Signing Key"); + + // Valid from now to five years from now. + Date now = new Date(); + final long kMilliSecsInOneYear = 365L * 24 * 60 * 60 * 1000; + Date expirationDate = new Date(now.getTime() + 5 * kMilliSecsInOneYear); + BigInteger serial = new BigInteger("42"); + JcaX509v3CertificateBuilder builder = + new JcaX509v3CertificateBuilder(issuer, + serial, + now, + expirationDate, + subject, + issuerAuthorityKeyPair.getPublic()); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256withECDSA") + .build(issuerAuthorityKeyPair.getPrivate()); + + X509CertificateHolder certHolder = builder.build(signer); + return new JcaX509CertificateConverter().getCertificate(certHolder); + } + + private void migrateAndCheckResults(String credName, + byte[] challenge, + Map> expectedNsData) throws Exception { + // get KeystoreIdentityCredential + KeystoreIdentityCredential keystoreCred = (KeystoreIdentityCredential) mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); + assertNotNull(keystoreCred); + + // migrate + CredentialStore credentialStore = new CredentialStore( + mStorageEngine, + mKeystoreEngineRepository); + Credential migratedCred = keystoreCred.migrateToCredentialStore(credentialStore); + + // check deletion + assertNull(mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256)); + + // ensure all namespace data is as expected + NameSpacedData nsData = migratedCred.getNameSpacedData(); + List resultNamespaces = nsData.getNameSpaceNames(); + assertEquals(resultNamespaces.size(), expectedNsData.keySet().size()); + assertTrue(resultNamespaces.containsAll(expectedNsData.keySet())); + for (String namespace : expectedNsData.keySet()) { + for (String dataElemId : expectedNsData.get(namespace).keySet()) { + assertArrayEquals(expectedNsData.get(namespace).get(dataElemId), nsData.getDataElement(namespace, dataElemId)); + } + } + + // check key alias + attestation + String aliasForOldCredKey = CredentialData.getAliasFromCredentialName(credName); // "identity_credential_credkey_givenCredName" + assertEquals(aliasForOldCredKey, migratedCred.getCredentialKeyAlias()); + + KeyStore ks; + List certChain = new ArrayList<>(); + Certificate[] certificateChain; + try { + ks = KeyStore.getInstance("AndroidKeyStore"); + ks.load(null); + certificateChain = ks.getCertificateChain(aliasForOldCredKey); + for (Certificate cert : certificateChain) { + certChain.add((X509Certificate) cert); + } + } catch (CertificateException + | KeyStoreException + | IOException + | NoSuchAlgorithmException e) { + throw new IllegalStateException("Error generate certificate chain", e); + } + assertEquals(certChain, migratedCred.getAttestation()); + + // Check the attestation extension + AndroidAttestationExtensionParser parser = new AndroidAttestationExtensionParser(certChain.get(0)); + Assert.assertArrayEquals("SomeChallenge".getBytes(), parser.getAttestationChallenge()); + AndroidAttestationExtensionParser.SecurityLevel securityLevel = parser.getKeymasterSecurityLevel(); + Assert.assertEquals(AndroidAttestationExtensionParser.SecurityLevel.TRUSTED_ENVIRONMENT, securityLevel); + + // Check we can load the credential... + migratedCred = credentialStore.lookupCredential(credName); + Assert.assertNotNull(migratedCred); + Assert.assertEquals(credName, migratedCred.getName()); + List certChain2 = migratedCred.getAttestation(); + Assert.assertEquals(certChain.size(), certChain2.size()); + for (int n = 0; n < certChain.size(); n++) { + Assert.assertEquals(certChain.get(n), certChain2.get(n)); + } + + // Create pending authentication key and check its attestation + assertEquals(0, migratedCred.getAuthenticationKeys().size()); + assertEquals(0, migratedCred.getPendingAuthenticationKeys().size()); + assertNull(migratedCred.findAuthenticationKey(Timestamp.ofEpochMilli(100))); + byte[] authKeyChallenge = new byte[] {20, 21, 22}; + Credential.PendingAuthenticationKey pendingAuthenticationKey = + migratedCred.createPendingAuthenticationKey(new AndroidKeystore.CreateKeySettings.Builder(authKeyChallenge) + .setUserAuthenticationRequired(true, 30*1000) + .build(), + null); + parser = new AndroidAttestationExtensionParser(pendingAuthenticationKey.getAttestation().get(0)); + Assert.assertArrayEquals(authKeyChallenge, + parser.getAttestationChallenge()); + Assert.assertEquals(AndroidAttestationExtensionParser.SecurityLevel.TRUSTED_ENVIRONMENT, + parser.getKeymasterSecurityLevel()); + + + // Generate an MSO and issuer-signed data for this authentication key. + Timestamp timeBeforeValidity = Timestamp.ofEpochMilli(40); + Timestamp timeValidityBegin = Timestamp.ofEpochMilli(50); + Timestamp timeDuringValidity = Timestamp.ofEpochMilli(100); + Timestamp timeValidityEnd = Timestamp.ofEpochMilli(150); + Timestamp timeAfterValidity = Timestamp.ofEpochMilli(200); + MobileSecurityObjectGenerator msoGenerator = new MobileSecurityObjectGenerator( + "SHA-256", + DOC_TYPE, + pendingAuthenticationKey.getAttestation().get(0).getPublicKey()); + msoGenerator.setValidityInfo(timeBeforeValidity, timeValidityBegin, timeValidityEnd, null); + + Random deterministicRandomProvider = new Random(42); + Map> issuerNameSpaces = MdocUtil.generateIssuerNameSpaces( + nsData, + deterministicRandomProvider, + 16); + + for (String nameSpaceName : issuerNameSpaces.keySet()) { + Map digests = MdocUtil.calculateDigestsForNameSpace( + nameSpaceName, + issuerNameSpaces, + "SHA-256"); + msoGenerator.addDigestIdsForNamespace(nameSpaceName, digests); + } + + KeyPair issuerKeyPair = generateIssuingAuthorityKeyPair(); + X509Certificate issuerCert = getSelfSignedIssuerAuthorityCertificate(issuerKeyPair); + + byte[] mso = msoGenerator.generate(); + byte[] taggedEncodedMso = Util.cborEncode(Util.cborBuildTaggedByteString(mso)); + + // IssuerAuth is a COSE_Sign1 where payload is MobileSecurityObjectBytes + // + // MobileSecurityObjectBytes = #6.24(bstr .cbor MobileSecurityObject) + // + ArrayList issuerCertChain = new ArrayList<>(); + issuerCertChain.add(issuerCert); + byte[] encodedIssuerAuth = Util.cborEncode(Util.coseSign1Sign(issuerKeyPair.getPrivate(), + "SHA256withECDSA", taggedEncodedMso, + null, + issuerCertChain)); + + byte[] issuerProvidedAuthenticationData = new StaticAuthDataGenerator( + MdocUtil.stripIssuerNameSpaces(issuerNameSpaces), + encodedIssuerAuth).generate(); + + // Now that we have issuer-provided authentication data we certify the authentication key. + Credential.AuthenticationKey authKey = pendingAuthenticationKey.certify( + issuerProvidedAuthenticationData, + timeValidityBegin, + timeValidityEnd); + + Assert.assertEquals(1, migratedCred.getAuthenticationKeys().size()); + Assert.assertEquals(0, migratedCred.getPendingAuthenticationKeys().size()); + + // If at a time before anything is valid, should not be able to present + Assert.assertNull(migratedCred.findAuthenticationKey(timeBeforeValidity)); + + // Ditto for right after + Assert.assertNull(migratedCred.findAuthenticationKey(timeAfterValidity)); + + // Check we're able to present at a time when the auth keys are valid + authKey = migratedCred.findAuthenticationKey(timeDuringValidity); + Assert.assertNotNull(authKey); + + Assert.assertEquals(0, authKey.getUsageCount()); + authKey.increaseUsageCount(); + Assert.assertEquals(1, authKey.getUsageCount()); + + } + + @Test + public void singleNamespaceMultipleACP() throws Exception { + Map> namespacedData = new HashMap<>(); + Map dataForNs = new HashMap<>(); + + // Profile 0 (no authentication) + AccessControlProfile noAuthProfile = + new AccessControlProfile.Builder(new AccessControlProfileId(0)) + .setUserAuthenticationRequired(false) + .build(); + + byte[] drivingPrivileges = getExampleDrivingPrivilegesCbor(); + + Collection idsNoAuth = new ArrayList(); + idsNoAuth.add(new AccessControlProfileId(0)); + Collection idsNoAcp = new ArrayList(); + String mdlNs = "org.iso.18013-5.2019"; + PersonalizationData personalizationData = + new PersonalizationData.Builder() + .addAccessControlProfile(noAuthProfile) + .putEntry(mdlNs, "First name", idsNoAuth, Util.cborEncodeString("Alan")) + .putEntry(mdlNs, "Last name", idsNoAuth, Util.cborEncodeString("Turing")) + .putEntry(mdlNs, "Home address", idsNoAuth, + Util.cborEncodeString("Maida Vale, London, England")) + .putEntry(mdlNs, "Birth date", idsNoAuth, + Util.cborEncodeString("19120623")) + .putEntry(mdlNs, "Cryptanalyst", idsNoAuth, Util.cborEncodeBoolean(true)) + .putEntry(mdlNs, "Portrait image", idsNoAuth, Util.cborEncodeBytestring( + new byte[]{0x01, 0x02})) + .putEntry(mdlNs, "Height", idsNoAuth, Util.cborEncodeNumber(180)) + .putEntry(mdlNs, "Neg Item", idsNoAuth, Util.cborEncodeNumber(-42)) + .putEntry(mdlNs, "Int Two Bytes", idsNoAuth, Util.cborEncodeNumber(0x101)) + .putEntry(mdlNs, "Int Four Bytes", idsNoAuth, + Util.cborEncodeNumber(0x10001)) + .putEntry(mdlNs, "Int Eight Bytes", idsNoAuth, + Util.cborEncodeNumber(0x100000001L)) + .putEntry(mdlNs, "driving_privileges", idsNoAuth, drivingPrivileges) + .putEntry(mdlNs, "No Access", idsNoAcp, + Util.cborEncodeString("Cannot be retrieved")) + .build(); + dataForNs.put("First name", Util.cborEncodeString("Alan")); + dataForNs.put("Last name", Util.cborEncodeString("Turing")); + dataForNs.put("Home address", Util.cborEncodeString("Maida Vale, London, England")); + dataForNs.put("Birth date", Util.cborEncodeString("19120623")); + dataForNs.put("Cryptanalyst", Util.cborEncodeBoolean(true)); + dataForNs.put("Portrait image", Util.cborEncodeBytestring(new byte[]{0x01, 0x02})); + dataForNs.put("Height", Util.cborEncodeNumber(180)); + dataForNs.put("Neg Item", Util.cborEncodeNumber(-42)); + dataForNs.put("Int Two Bytes", Util.cborEncodeNumber(0x101)); + dataForNs.put("Int Four Bytes", Util.cborEncodeNumber(0x10001)); + dataForNs.put("Int Eight Bytes", Util.cborEncodeNumber(0x100000001L)); + dataForNs.put("driving_privileges", drivingPrivileges); + dataForNs.put("No Access", Util.cborEncodeString("Cannot be retrieved")); + namespacedData.put(mdlNs, dataForNs); + + String credName = "test1"; + byte[] challenge = "SomeChallenge".getBytes(); + mICStore.deleteCredentialByName(credName); + createCredentialWithPersonalizationData(mICStore, credName, challenge, personalizationData); + KeystoreIdentityCredential keystoreCred = (KeystoreIdentityCredential) mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); + assertNotNull(keystoreCred); + + migrateAndCheckResults(credName, challenge, namespacedData); + } + + @Test + public void multipleNamespaceSameData() throws Exception { + Map> namespacedData = new HashMap<>(); + Map dataForNs = new HashMap<>(); + + byte[] drivingPrivileges = getExampleDrivingPrivilegesCbor(); + + dataForNs.put("First name", Util.cborEncodeString("Alan")); + dataForNs.put("Last name", Util.cborEncodeString("Turing")); + dataForNs.put("Home address", Util.cborEncodeString("Maida Vale, London, England")); + dataForNs.put("Birth date", Util.cborEncodeString("19120623")); + dataForNs.put("Cryptanalyst", Util.cborEncodeBoolean(true)); + dataForNs.put("Portrait image", Util.cborEncodeBytestring(new byte[]{0x01, 0x02})); + dataForNs.put("Height", Util.cborEncodeNumber(180)); + dataForNs.put("Neg Item", Util.cborEncodeNumber(-42)); + dataForNs.put("Int Two Bytes", Util.cborEncodeNumber(0x101)); + dataForNs.put("Int Four Bytes", Util.cborEncodeNumber(0x10001)); + dataForNs.put("Int Eight Bytes", Util.cborEncodeNumber(0x100000001L)); + dataForNs.put("driving_privileges", drivingPrivileges); + dataForNs.put("No Access", Util.cborEncodeString("Cannot be retrieved")); + + namespacedData.put("org.iso.18013-5.2019", dataForNs); + namespacedData.put("test.org.iso.18013-5.2019", dataForNs); + + String credName = "test2"; + byte[] challenge = "SomeChallenge".getBytes(); + mICStore.deleteCredentialByName(credName); + createCredentialWithChallenge(mICStore, credName, challenge, namespacedData); + KeystoreIdentityCredential keystoreCred = (KeystoreIdentityCredential) mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); + assertNotNull(keystoreCred); + + migrateAndCheckResults(credName, challenge, namespacedData); + } + + @Test + public void multipleNamespaceOneEmpty() throws Exception { + Map> namespacedData = new HashMap<>(); + Map dataForNs1 = new HashMap<>(); + Map dataForNs2 = new HashMap<>(); + Map dataForNs3 = new HashMap<>(); + + byte[] drivingPrivileges = getExampleDrivingPrivilegesCbor(); + + dataForNs1.put("First name", Util.cborEncodeString("Alan")); + dataForNs1.put("Last name", Util.cborEncodeString("Turing")); + dataForNs1.put("Home address", Util.cborEncodeString("Maida Vale, London, England")); + dataForNs1.put("Birth date", Util.cborEncodeString("19120623")); + dataForNs2.put("Cryptanalyst", Util.cborEncodeBoolean(true)); + dataForNs2.put("Portrait image", Util.cborEncodeBytestring(new byte[]{0x01, 0x02})); + dataForNs2.put("Height", Util.cborEncodeNumber(180)); + dataForNs2.put("Neg Item", Util.cborEncodeNumber(-42)); + dataForNs2.put("Int Two Bytes", Util.cborEncodeNumber(0x101)); + dataForNs2.put("Int Four Bytes", Util.cborEncodeNumber(0x10001)); + dataForNs2.put("Int Eight Bytes", Util.cborEncodeNumber(0x100000001L)); + dataForNs2.put("driving_privileges", drivingPrivileges); + dataForNs2.put("No Access", Util.cborEncodeString("Cannot be retrieved")); + dataForNs3.put("Random name", Util.cborEncodeBytestring(new byte[]{0x01, 0x02})); + + namespacedData.put("namespace 1", dataForNs1); + namespacedData.put("namespace 2", dataForNs2); + namespacedData.put("", dataForNs3); + + String credName = "test3"; + byte[] challenge = "SomeChallenge".getBytes(); + mICStore.deleteCredentialByName(credName); + createCredentialWithChallenge(mICStore, credName, challenge, namespacedData); + KeystoreIdentityCredential keystoreCred = (KeystoreIdentityCredential) mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); + assertNotNull(keystoreCred); + + migrateAndCheckResults(credName, challenge, namespacedData); + } + + @Test + public void deleteBeforeMigrationTest() throws Exception { + Map> namespacedData = new HashMap<>(); + Map dataForNs1 = new HashMap<>(); + + dataForNs1.put("First name", Util.cborEncodeString("Alan")); + namespacedData.put("namespace 1", dataForNs1); + + String credName = "testFailure"; + byte[] challenge = "SomeChallenge".getBytes(); + mICStore.deleteCredentialByName(credName); + createCredentialWithChallenge(mICStore, credName, challenge, namespacedData); + KeystoreIdentityCredential keystoreCred = (KeystoreIdentityCredential) mICStore.getCredentialByName( + credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256); + assertNotNull(keystoreCred); + + // delete and try to migrate + mICStore.deleteCredentialByName(credName); + CredentialStore credentialStore = new CredentialStore( + mStorageEngine, + mKeystoreEngineRepository); + assertNull(mICStore.getCredentialByName(credName, IdentityCredentialStore.CIPHERSUITE_ECDHE_HKDF_ECDSA_WITH_AES_256_GCM_SHA256)); + assertThrows(IllegalStateException.class, () -> keystoreCred.migrateToCredentialStore(credentialStore)); + } +} diff --git a/identity-android/src/main/java/com/android/identity/android/keystore/AndroidKeystore.java b/identity-android/src/main/java/com/android/identity/android/keystore/AndroidKeystore.java index be9351858..1790527d3 100644 --- a/identity-android/src/main/java/com/android/identity/android/keystore/AndroidKeystore.java +++ b/identity-android/src/main/java/com/android/identity/android/keystore/AndroidKeystore.java @@ -153,6 +153,11 @@ public AndroidKeystore(@NonNull Context context, public void createKey(@NonNull String alias, @NonNull KeystoreEngine.CreateKeySettings createKeySettings) { CreateKeySettings aSettings = (CreateKeySettings) createKeySettings; + if (aSettings.getExistingKeyAlias() != null) { + createFromExistingKey(aSettings.getExistingKeyAlias(), aSettings); + return; + } + KeyPairGenerator kpg = null; try { kpg = KeyPairGenerator.getInstance( @@ -271,6 +276,26 @@ public void createKey(@NonNull String alias, saveKeyMetadata(alias, aSettings, attestation); } + private void createFromExistingKey(@NonNull String existingKeyAlias, CreateKeySettings aSettings) { + List attestation = new ArrayList<>(); + try { + KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); + ks.load(null); + Certificate[] certificates = ks.getCertificateChain(existingKeyAlias); + for (Certificate certificate : certificates) { + attestation.add((X509Certificate) certificate); + } + } catch (CertificateException + | KeyStoreException + | IOException + | NoSuchAlgorithmException e) { + throw new IllegalStateException("Error generating certificate chain", e); + } + + saveKeyMetadata(existingKeyAlias, aSettings, attestation); + Logger.d(TAG, "EC key with alias '" + existingKeyAlias + "' transferred"); + } + @Override public void deleteKey(@NonNull String alias) { KeyStore ks; @@ -765,6 +790,7 @@ public static class CreateKeySettings extends KeystoreEngine.CreateKeySettings { private final String mAttestKeyAlias; private final Timestamp mValidFrom; private final Timestamp mValidUntil; + private final String mExistingKeyAlias; private CreateKeySettings(@KeyPurpose int keyPurpose, @EcCurve int ecCurve, @@ -774,7 +800,8 @@ private CreateKeySettings(@KeyPurpose int keyPurpose, boolean useStrongBox, @Nullable String attestKeyAlias, @Nullable Timestamp validFrom, - @Nullable Timestamp validUntil) { + @Nullable Timestamp validUntil, + @Nullable String existingKeyAlias) { super(AndroidKeystore.class); mKeyPurposes = keyPurpose; mEcCurve = ecCurve; @@ -785,6 +812,7 @@ private CreateKeySettings(@KeyPurpose int keyPurpose, mAttestKeyAlias = attestKeyAlias; mValidFrom = validFrom; mValidUntil = validUntil; + mExistingKeyAlias = existingKeyAlias; } /** @@ -868,6 +896,14 @@ public boolean getUseStrongBox() { return mValidUntil; } + /** + * If the CreateKeySettings represents a key which already exists, returns the alias of the + * key which it represents. Otherwise, returns {@code null}. + * + * @return the alias or {@code null} if not set. + */ + public @Nullable String getExistingKeyAlias() { return mExistingKeyAlias; } + /** * A builder for {@link CreateKeySettings}. */ @@ -881,6 +917,7 @@ public static class Builder { private String mAttestKeyAlias; private Timestamp mValidFrom; private Timestamp mValidUntil; + private String mExistingKeyAlias; /** * Constructor. @@ -982,6 +1019,11 @@ public Builder(@NonNull byte[] attestationChallenge) { return this; } + public @NonNull Builder setExistingKeyAlias(@NonNull String alias) { + mExistingKeyAlias = alias; + return this; + } + /** * Builds the {@link CreateKeySettings}. * @@ -997,7 +1039,8 @@ public Builder(@NonNull byte[] attestationChallenge) { mUseStrongBox, mAttestKeyAlias, mValidFrom, - mValidUntil); + mValidUntil, + mExistingKeyAlias); } } diff --git a/identity-android/src/main/java/com/android/identity/android/legacy/CredentialData.java b/identity-android/src/main/java/com/android/identity/android/legacy/CredentialData.java index f154d5a54..c66ebd806 100644 --- a/identity-android/src/main/java/com/android/identity/android/legacy/CredentialData.java +++ b/identity-android/src/main/java/com/android/identity/android/legacy/CredentialData.java @@ -655,6 +655,59 @@ static byte[] delete(@NonNull Context context, @NonNull File storageDirectory, return signature; } + static boolean deleteForMigration(@NonNull Context context, @NonNull File storageDirectory, + @NonNull String credentialName) { + String filename = getFilenameForCredentialData(credentialName); + AtomicFile file = new AtomicFile(new File(storageDirectory, filename)); + try { + file.openRead(); + } catch (FileNotFoundException e) { + return false; + } + + CredentialData data = new CredentialData(context, storageDirectory, credentialName); + String dataKeyAlias = getDataKeyAliasFromCredentialName(credentialName); + try { + data.loadFromDisk(dataKeyAlias); + } catch (RuntimeException e) { + Log.e(TAG, "Error parsing file on disk (old version?). Deleting anyway."); + } + file.delete(); + + KeyStore ks; + try { + ks = KeyStore.getInstance("AndroidKeyStore"); + ks.load(null); + } catch (CertificateException + | IOException + | NoSuchAlgorithmException + | KeyStoreException e) { + throw new RuntimeException("Error loading keystore", e); + } + + // Nuke all keys. + try { + if (!data.mPerReaderSessionKeyAlias.isEmpty()) { + ks.deleteEntry(data.mPerReaderSessionKeyAlias); + } + for (String alias : data.mAcpTimeoutKeyAliases.values()) { + ks.deleteEntry(alias); + } + for (AuthKeyData authKeyData : data.mAuthKeyDatas) { + if (!authKeyData.mAlias.isEmpty()) { + ks.deleteEntry(authKeyData.mAlias); + } + if (!authKeyData.mPendingAlias.isEmpty()) { + ks.deleteEntry(authKeyData.mPendingAlias); + } + } + } catch (KeyStoreException e) { + throw new RuntimeException("Error deleting key", e); + } + + return true; + } + private void createDataEncryptionKey() { // TODO: it could maybe be nice to encrypt data with the appropriate auth-bound // key (the one associated with the ACP with the longest timeout), if it doesn't diff --git a/identity-android/src/main/java/com/android/identity/android/legacy/KeystoreIdentityCredential.java b/identity-android/src/main/java/com/android/identity/android/legacy/KeystoreIdentityCredential.java index 26cc43a73..5a44f41ea 100644 --- a/identity-android/src/main/java/com/android/identity/android/legacy/KeystoreIdentityCredential.java +++ b/identity-android/src/main/java/com/android/identity/android/legacy/KeystoreIdentityCredential.java @@ -19,6 +19,7 @@ import android.content.Context; import android.icu.util.Calendar; import android.security.keystore.KeyProperties; +import android.util.AtomicFile; import android.util.Log; import android.util.Pair; @@ -26,11 +27,17 @@ import androidx.annotation.Nullable; import androidx.biometric.BiometricPrompt; +import com.android.identity.android.keystore.AndroidKeystore; +import com.android.identity.credential.Credential; +import com.android.identity.credential.CredentialStore; +import com.android.identity.credential.NameSpacedData; import com.android.identity.internal.Util; +import com.android.identity.keystore.KeystoreEngine; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; +import java.io.FileNotFoundException; import java.io.IOException; import java.nio.ByteBuffer; import java.security.InvalidAlgorithmParameterException; @@ -818,4 +825,52 @@ List getAuthenticationDataExpirations() { return mData.getAuthKeyExpirations(); } + // tested in identity-android/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java + /** + * Gathers all the {@link PersonalizationData.NamespaceData} from this credential and creates a + * new {@link Credential} with this data inside the given {@link CredentialStore}. The key used + * by the {@link CredentialData} in this credential is preserved, and this method will also pass + * metadata for this key to the new {@link Credential} for future usage. Once the new + * {@link Credential} is created, this method will delete the file with encrypted + * {@link CredentialData} as well as any per reader session keys, acp timeout keys, and auth keys. + * + *

The returned {@link Credential} will also have the same name as this credential, so it can + * be retrieved from the given {@link CredentialStore} using + * {@link CredentialStore#lookupCredential(String)} with the same name. + * + *

In total, the data within each namespace and the credential key will be migrated to the + * new {@link Credential}, while the access control profile information, per reader session/acp + * timeout/auth keys will not be transferred. + * + * @param credentialStore the credential store where the new {@link Credential} should be stored. + * @return the new {@link Credential}. + */ + public @NonNull Credential migrateToCredentialStore(@NonNull CredentialStore credentialStore) { + loadData(); + + if (mData == null) { + throw new IllegalStateException("The credential has been deleted prior to migration."); + } + String aliasForOldCredKey = mData.getCredentialKeyAlias(); + AndroidKeystore.CreateKeySettings.Builder keySettingsBuilder = Utility.extractKeySettings(aliasForOldCredKey); + keySettingsBuilder.setEcCurve(KeystoreEngine.EC_CURVE_P256); + + Credential newCred = credentialStore.createCredentialWithExistingKey(mCredentialName, + keySettingsBuilder.build(), aliasForOldCredKey); + + NameSpacedData.Builder nsBuilder = new NameSpacedData.Builder(); + for (PersonalizationData.NamespaceData namespaceData : mData.getNamespaceDatas()) { + for (String entryName : namespaceData.getEntryNames()) { + byte[] value = namespaceData.getEntryValue(entryName); + nsBuilder.putEntry(namespaceData.mNamespace, entryName, value); + } + } + + newCred.setNameSpacedData(nsBuilder.build()); + + CredentialData.deleteForMigration(mContext, mStorageDirectory, mCredentialName); + + return newCred; + } + } diff --git a/identity-android/src/main/java/com/android/identity/android/legacy/Utility.java b/identity-android/src/main/java/com/android/identity/android/legacy/Utility.java index 921b601d3..42815680b 100644 --- a/identity-android/src/main/java/com/android/identity/android/legacy/Utility.java +++ b/identity-android/src/main/java/com/android/identity/android/legacy/Utility.java @@ -20,14 +20,17 @@ import android.content.Context; import android.icu.util.Calendar; +import android.os.Build; +import android.security.keystore.KeyInfo; import android.security.keystore.KeyProperties; import android.util.Log; import androidx.annotation.Nullable; -import androidx.core.util.Pair; import androidx.annotation.NonNull; +import com.android.identity.android.keystore.AndroidKeystore; +import com.android.identity.keystore.KeystoreEngine; import com.android.identity.mdoc.mso.StaticAuthDataGenerator; import com.android.identity.mdoc.response.DeviceResponseGenerator; import com.android.identity.mdoc.mso.MobileSecurityObjectGenerator; @@ -35,17 +38,25 @@ import com.android.identity.util.Timestamp; import com.android.identity.internal.Util; +import java.io.IOException; import java.security.InvalidAlgorithmParameterException; +import java.security.KeyFactory; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.SecureRandom; +import java.security.UnrecoverableKeyException; import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import java.security.spec.ECGenParameterSpec; +import java.security.spec.InvalidKeySpecException; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; @@ -57,12 +68,7 @@ import java.util.Random; import co.nstant.in.cbor.CborBuilder; -import co.nstant.in.cbor.builder.ArrayBuilder; -import co.nstant.in.cbor.builder.MapBuilder; -import co.nstant.in.cbor.model.ByteString; import co.nstant.in.cbor.model.DataItem; -import co.nstant.in.cbor.model.SimpleValue; -import co.nstant.in.cbor.model.SimpleValueType; import co.nstant.in.cbor.model.UnicodeString; /** @@ -356,4 +362,70 @@ public static IdentityCredentialStore getIdentityCredentialStore(@NonNull Contex //return IdentityCredentialStore.getHardwareInstance(context); return IdentityCredentialStore.getKeystoreInstance(context, context.getNoBackupFilesDir()); } + + private static @KeystoreEngine.KeyPurpose int convertKeyPurpose(KeyInfo keyInfo) { + @KeystoreEngine.KeyPurpose int keyPurposeIC = 0; + int keyPurposesAndroid = keyInfo.getPurposes(); + + if ((keyPurposesAndroid & KeyProperties.PURPOSE_AGREE_KEY) == KeyProperties.PURPOSE_AGREE_KEY) { + keyPurposeIC = keyPurposeIC | KeystoreEngine.KEY_PURPOSE_AGREE_KEY; + } + if ((keyPurposesAndroid & KeyProperties.PURPOSE_SIGN) == KeyProperties.PURPOSE_SIGN) { + keyPurposeIC = keyPurposeIC | KeystoreEngine.KEY_PURPOSE_SIGN; + } + return keyPurposeIC; + } + + public static @NonNull AndroidKeystore.CreateKeySettings.Builder extractKeySettings(String keyAlias) { + KeyStore ks; + PrivateKey privateKey; + try { + ks = KeyStore.getInstance("AndroidKeyStore"); + ks.load(null); + privateKey = (PrivateKey) ks.getKey(keyAlias, null); + } catch (CertificateException + | KeyStoreException + | IOException + | NoSuchAlgorithmException e) { + throw new IllegalStateException("Error generate certificate chain", e); + } catch (UnrecoverableKeyException e) { + throw new IllegalStateException("Error retrieving key", e); + } + + KeyInfo keyInfo; + try { + KeyFactory factory = KeyFactory.getInstance(privateKey.getAlgorithm(), "AndroidKeyStore"); + keyInfo = factory.getKeySpec(privateKey, KeyInfo.class); + } catch (InvalidKeySpecException e) { + throw new IllegalStateException("Unrecoverable Key: Not an Android KeyStore key", e); + } catch (NoSuchAlgorithmException + | NoSuchProviderException e) { + throw new RuntimeException(e); + } + + // attestation challenge will have no impact since a key will not be created with these settings + byte[] credentialKeyAttestationChallenge = new byte[] {10, 11, 12}; + AndroidKeystore.CreateKeySettings.Builder keySettingsBuilder = + new AndroidKeystore.CreateKeySettings.Builder(credentialKeyAttestationChallenge); + + keySettingsBuilder.setExistingKeyAlias(keyAlias); + keySettingsBuilder.setKeyPurposes(convertKeyPurpose(keyInfo)); + if (keyInfo.isUserAuthenticationRequired()) { + long userAuthenticationTimeoutMillis = keyInfo.getUserAuthenticationValidityDurationSeconds() * 1000L; + keySettingsBuilder.setUserAuthenticationRequired(true, userAuthenticationTimeoutMillis); + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + if ((keyInfo.getSecurityLevel() & KeyProperties.SECURITY_LEVEL_STRONGBOX) == + KeyProperties.SECURITY_LEVEL_STRONGBOX) { + keySettingsBuilder.setUseStrongBox(true); + } + } + if (keyInfo.getKeyValidityStart() != null & keyInfo.getKeyValidityForOriginationEnd() != null) { + Timestamp validFrom = Timestamp.ofEpochMilli(keyInfo.getKeyValidityStart().getTime()); + Timestamp validUntil = Timestamp.ofEpochMilli(keyInfo.getKeyValidityForOriginationEnd().getTime()); + keySettingsBuilder.setValidityPeriod(validFrom, validUntil); + } + + return keySettingsBuilder; + } } \ No newline at end of file diff --git a/identity/src/main/java/com/android/identity/credential/Credential.java b/identity/src/main/java/com/android/identity/credential/Credential.java index 00cf43c25..63b716f38 100644 --- a/identity/src/main/java/com/android/identity/credential/Credential.java +++ b/identity/src/main/java/com/android/identity/credential/Credential.java @@ -144,6 +144,35 @@ static Credential create(@NonNull StorageEngine storageEngine, return credential; } + // Called by CredentialStore.createCredentialWithExistingKey(). + static @NonNull Credential createWithExistingKey( + @NonNull StorageEngine storageEngine, + @NonNull KeystoreEngineRepository keystoreEngineRepository, + @NonNull String name, + @NonNull KeystoreEngine.CreateKeySettings credentialKeySettings, + @NonNull String existingKeyAlias) { + + Credential credential = new Credential(storageEngine, keystoreEngineRepository); + credential.mName = name; + + String keystoreEngineClassName = credentialKeySettings.getKeystoreEngineClass().getName(); + if (!keystoreEngineClassName.equals("com.android.identity.android.keystore.AndroidKeystore")) { + throw new IllegalStateException("The function must only be called for credentials in " + + "AndroidKeystore, not " + keystoreEngineClassName); + } + + credential.mKeystoreEngine = keystoreEngineRepository.getImplementation(keystoreEngineClassName); + if (credential.mKeystoreEngine == null) { + throw new IllegalStateException("No KeystoreEngine with name " + keystoreEngineClassName); + } + + credential.mCredentialKeyAlias = existingKeyAlias; + + credential.mKeystoreEngine.createKey(credential.mCredentialKeyAlias, credentialKeySettings); + + return credential; + } + private void saveCredential() { CborBuilder builder = new CborBuilder(); MapBuilder map = builder.addMap(); diff --git a/identity/src/main/java/com/android/identity/credential/CredentialStore.java b/identity/src/main/java/com/android/identity/credential/CredentialStore.java index 38d0acbea..8468b099f 100644 --- a/identity/src/main/java/com/android/identity/credential/CredentialStore.java +++ b/identity/src/main/java/com/android/identity/credential/CredentialStore.java @@ -77,6 +77,28 @@ public CredentialStore(@NonNull StorageEngine storageEngine, credentialKeySettings); } + /** + * Creates a new credential using a key which already exists in some keystore. + * + *

If a credential with the given name already exists, it will be overwritten by the + * newly created credential. + * + * @param name name of the credential. + * @param credentialKeySettings the settings to use for CredentialKey. + * @param existingKeyAlias the alias of the existing key. + * @return A newly created credential. + */ + public @NonNull Credential createCredentialWithExistingKey( + @NonNull String name, + @NonNull KeystoreEngine.CreateKeySettings credentialKeySettings, + @NonNull String existingKeyAlias) { + return Credential.createWithExistingKey(mStorageEngine, + mKeystoreEngineRepository, + name, + credentialKeySettings, + existingKeyAlias); + } + /** * Looks up a previously created credential. *