From 2d6ad273518bcae9472ee3ef5de50335f0e1d430 Mon Sep 17 00:00:00 2001 From: Suzanna Jiwani Date: Tue, 25 Jul 2023 16:21:39 -0400 Subject: [PATCH] Add Migrate Function For KeystoreIdentityCredential Added a function to the KeystoreIdentityCredential class that creates a new Credential (identity/src/main/java/com/android/identity/credential/Credential.java) using the information in the given KeystoreIdentityCredential and deletes the KeystoreIdentityCredential once the new Credential is created. This change required additional functions in the Credential, CredentialStore, and AndroidKeystore classes which allow Credential creation with an existing key so the credential key from the KeystoreIdentityCredential could be preserved. A function in Utility.java to extract key settings from an existing key as well as a function in CredentialData.java to delete credential information while preserving the key were also added to aid in this process. Tested in MigrateFromKeystoreICStoreTest. --- .../MigrateFromKeystoreICStoreTest.java | 552 ++++++++++++++++++ .../android/keystore/AndroidKeystore.java | 47 +- .../android/legacy/CredentialData.java | 53 ++ .../legacy/KeystoreIdentityCredential.java | 55 ++ .../identity/android/legacy/Utility.java | 84 ++- .../identity/credential/Credential.java | 29 + .../identity/credential/CredentialStore.java | 22 + 7 files changed, 834 insertions(+), 8 deletions(-) create mode 100644 identity-android/src/androidTest/java/com/android/identity/android/legacy/MigrateFromKeystoreICStoreTest.java 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 fd267c847..a44e086fb 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; /** @@ -396,4 +402,70 @@ public static IdentityCredentialStore getIdentityCredentialStore(@NonNull Contex throw new IllegalStateException("Error generating ephemeral key-pair", e); } } + + 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. *