Implement Key Attestation for K-Anon
Test: atest com.android.adservices.service.kanon.KeyAttestationTest,
atest
com.android.adservices.service.kanon.KeyAttestationCertificateChainRecordTest
Change-Id: I827ef5eb9c648158dc7f2cc297672941cd807888
diff --git a/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestation.java b/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestation.java
new file mode 100644
index 0000000..ef3df8c
--- /dev/null
+++ b/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestation.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2024 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.adservices.service.kanon;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.security.keystore.KeyGenParameterSpec;
+import android.security.keystore.KeyProperties;
+import android.security.keystore.StrongBoxUnavailableException;
+
+import com.android.adservices.LoggerFactory;
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.io.IOException;
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.spec.ECGenParameterSpec;
+import java.util.ArrayList;
+
+/** Provides method to generate secure hardware key backed X.509 certificate chain */
+public class KeyAttestation {
+
+ private static final LoggerFactory.Logger sLogger = LoggerFactory.getFledgeLogger();
+
+ private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
+
+ private static final String PA_KANON_KEY_ALIAS = "PaKanonKeyAttestation";
+
+ private static final String ELLIPTIC_CURVE_KEY_GENERATION_NAME = "secp256r1";
+
+ private final boolean mUseStrongBox;
+
+ private final KeyStore mKeyStore;
+ private final KeyPairGenerator mKeyPairGenerator;
+
+ @VisibleForTesting
+ KeyAttestation(Boolean useStrongBox, KeyStore keyStore, KeyPairGenerator keyPairGenerator) {
+ this.mUseStrongBox = useStrongBox;
+ this.mKeyStore = keyStore;
+ this.mKeyPairGenerator = keyPairGenerator;
+ }
+
+ /** Creates an instance of {@link KeyAttestation} */
+ public static KeyAttestation create(Context context)
+ throws KeyStoreException, NoSuchAlgorithmException, NoSuchProviderException {
+ return new KeyAttestation(
+ context.getPackageManager()
+ .hasSystemFeature(PackageManager.FEATURE_STRONGBOX_KEYSTORE),
+ KeyStore.getInstance(ANDROID_KEY_STORE),
+ KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE));
+ }
+
+ /**
+ * Given a challenge, return a {@link KeyAttestationCertificateChainRecord}. The attestation is
+ * performed using a 256-bit Elliptical Curve (EC) key-pair generated by the secure hardware.
+ */
+ public KeyAttestationCertificateChainRecord generateAttestationRecord(final byte[] challenge)
+ throws IllegalStateException {
+ KeyPair kp = generateHybridKey(challenge, PA_KANON_KEY_ALIAS);
+ if (kp == null) {
+ sLogger.e("Received a null key pair. Can't generate certificate.");
+ throw new IllegalStateException("Unable to generate a key pair");
+ }
+ return getAttestationRecordFromKeyAlias(PA_KANON_KEY_ALIAS);
+ }
+
+ @VisibleForTesting
+ KeyPair generateHybridKey(final byte[] challenge, final String keyAlias) {
+ try {
+ mKeyPairGenerator.initialize(
+ new KeyGenParameterSpec.Builder(
+ /* keystoreAlias= */ keyAlias,
+ /* purposes= */ KeyProperties.PURPOSE_SIGN)
+ .setDigests(KeyProperties.DIGEST_SHA256)
+ .setAlgorithmParameterSpec(
+ new ECGenParameterSpec(ELLIPTIC_CURVE_KEY_GENERATION_NAME))
+ .setAttestationChallenge(challenge)
+ .setIsStrongBoxBacked(mUseStrongBox)
+ .build());
+
+ return mKeyPairGenerator.generateKeyPair();
+ } catch (InvalidAlgorithmParameterException e) {
+ sLogger.e("Failed to generate EC key for attestation: " + e.getMessage());
+ throw new IllegalStateException("Failed to generate a key Pair", e);
+ } catch (StrongBoxUnavailableException e) {
+ sLogger.e(
+ "Strong box not available on device but isStrongBox is set to true: "
+ + e.getMessage());
+ throw new IllegalStateException("Failed to generate a key Pair", e);
+ }
+ }
+
+ @VisibleForTesting
+ KeyAttestationCertificateChainRecord getAttestationRecordFromKeyAlias(String keyAlias) {
+ try {
+ mKeyStore.load(null);
+ Certificate[] certificateChain = mKeyStore.getCertificateChain(keyAlias);
+ if (certificateChain == null) {
+ throw new IllegalStateException("Certificate chain was null");
+ }
+ ArrayList<byte[]> attestationRecord = new ArrayList<>();
+ for (Certificate certificate : certificateChain) {
+ attestationRecord.add(certificate.getEncoded());
+ }
+
+ return KeyAttestationCertificateChainRecord.create(attestationRecord);
+ } catch (CertificateException
+ | IOException
+ | KeyStoreException
+ | NoSuchAlgorithmException e) {
+ sLogger.e("Exception when" + "loading certs for attestation: " + e.getMessage());
+ return KeyAttestationCertificateChainRecord.create(new ArrayList<>());
+ }
+ }
+}
diff --git a/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecord.java b/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecord.java
new file mode 100644
index 0000000..b4f2619
--- /dev/null
+++ b/adservices/service-core/java/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecord.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2024 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.adservices.service.kanon;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+
+import dagger.internal.Preconditions;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+@AutoValue
+public abstract class KeyAttestationCertificateChainRecord {
+
+ private static final int BYTES_TO_ENCODE_CERTIFICATE_LENGTH = 4;
+
+ abstract ImmutableList<byte[]> getCertificateChain();
+
+ /** Create a KeyAttestation Record object with the given certificate chain */
+ public static KeyAttestationCertificateChainRecord create(List<byte[]> certificateChain) {
+ Preconditions.checkNotNull(certificateChain);
+ return new AutoValue_KeyAttestationCertificateChainRecord(
+ ImmutableList.copyOf(certificateChain));
+ }
+
+ /**
+ * Encode in a format K-Anon server can understand
+ *
+ * <p>The format is 4 bytes for the size of first certificate followed by the certificate, next
+ * 4 bytes for the size of the second certificate followed by the second certificate, and so on.
+ */
+ public byte[] encode() throws IOException {
+ List<byte[]> certificateChain = getCertificateChain();
+
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
+
+ for (byte[] certificate : certificateChain) {
+ outputStream.write(
+ ByteBuffer.allocate(BYTES_TO_ENCODE_CERTIFICATE_LENGTH)
+ .putInt(certificate.length)
+ .array());
+ outputStream.write(certificate);
+ }
+
+ return outputStream.toByteArray();
+ }
+}
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecordTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecordTest.java
new file mode 100644
index 0000000..28ce9d9
--- /dev/null
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationCertificateChainRecordTest.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2024 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.adservices.service.kanon;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.io.BaseEncoding;
+
+import org.junit.Test;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public final class KeyAttestationCertificateChainRecordTest {
+
+ private static final int BYTES_TO_ENCODE_CERTIFICATE_LENGTH = 4;
+ private static final byte[] FAKE_CERT_1 =
+ BaseEncoding.base16().lowerCase().decode("aabbccddee");
+ private static final byte[] FAKE_CERT_2 = BaseEncoding.base16().lowerCase().decode("112233");
+ private static final byte[] FAKE_CERT_3 =
+ BaseEncoding.base16().lowerCase().decode("11223322aabbddeeff4e");
+ private static final List<byte[]> AS_LIST =
+ Arrays.asList(FAKE_CERT_1, FAKE_CERT_2, FAKE_CERT_3);
+
+ @Test
+ public void test_serialize() throws IOException {
+ byte[] encoded = KeyAttestationCertificateChainRecord.create(AS_LIST).encode();
+
+ assertEquals(
+ 12 + FAKE_CERT_1.length + FAKE_CERT_2.length + FAKE_CERT_3.length, encoded.length);
+
+ byte[] length1 = new byte[BYTES_TO_ENCODE_CERTIFICATE_LENGTH];
+ System.arraycopy(encoded, 0, length1, 0, BYTES_TO_ENCODE_CERTIFICATE_LENGTH);
+ assertThat(ByteBuffer.wrap(length1).getInt()).isEqualTo(FAKE_CERT_1.length);
+ byte[] cert1 = new byte[FAKE_CERT_1.length];
+ System.arraycopy(encoded, 4, cert1, 0, FAKE_CERT_1.length);
+ assertArrayEquals(FAKE_CERT_1, cert1);
+
+ byte[] length2 = new byte[BYTES_TO_ENCODE_CERTIFICATE_LENGTH];
+ System.arraycopy(
+ encoded, 4 + FAKE_CERT_1.length, length2, 0, BYTES_TO_ENCODE_CERTIFICATE_LENGTH);
+ assertThat(ByteBuffer.wrap(length2).getInt()).isEqualTo(FAKE_CERT_2.length);
+ byte[] cert2 = new byte[FAKE_CERT_2.length];
+ System.arraycopy(encoded, 8 + FAKE_CERT_1.length, cert2, 0, FAKE_CERT_2.length);
+ assertArrayEquals(FAKE_CERT_2, cert2);
+
+ byte[] length3 = new byte[BYTES_TO_ENCODE_CERTIFICATE_LENGTH];
+ System.arraycopy(
+ encoded,
+ 8 + FAKE_CERT_1.length + FAKE_CERT_2.length,
+ length3,
+ 0,
+ BYTES_TO_ENCODE_CERTIFICATE_LENGTH);
+ assertThat(ByteBuffer.wrap(length3).getInt()).isEqualTo(FAKE_CERT_3.length);
+ byte[] cert3 = new byte[FAKE_CERT_3.length];
+ System.arraycopy(
+ encoded,
+ 12 + FAKE_CERT_1.length + FAKE_CERT_2.length,
+ cert3,
+ 0,
+ FAKE_CERT_3.length);
+ assertArrayEquals(FAKE_CERT_3, cert3);
+ assertThat(FAKE_CERT_1).isEqualTo(cert1);
+ }
+
+ @Test
+ public void test_passNull_throwsException() throws IOException {
+ assertThrows(
+ NullPointerException.class,
+ () -> KeyAttestationCertificateChainRecord.create(null).encode());
+ }
+
+ @Test
+ public void test_passEmpty_returnsEmptyByteArray() throws IOException {
+ byte[] encoded = KeyAttestationCertificateChainRecord.create(new ArrayList<>()).encode();
+ assertEquals(0, encoded.length);
+ }
+}
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationTest.java
new file mode 100644
index 0000000..f04d176
--- /dev/null
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/service/kanon/KeyAttestationTest.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2024 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.adservices.service.kanon;
+
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.any;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn;
+import static com.android.dx.mockito.inline.extended.ExtendedMockito.doThrow;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+import static org.mockito.Mockito.spy;
+
+import android.security.keystore.KeyProperties;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+
+import java.security.InvalidAlgorithmParameterException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.KeyStoreException;
+import java.security.cert.CertificateException;
+
+public final class KeyAttestationTest {
+ private static final byte[] CHALLENGE =
+ ("AHXUDhoSEFikqOefmo8xE7kGp/xjVMRDYBecBiHGxCN8rTv9W0Z4L/14d0OLB"
+ + "vC1VVzXBAnjgHoKLZzuJifTOaBJwGNIQ2ejnx3n6ayoRchDNCgpK29T+EAhBWzH")
+ .getBytes();
+
+ private static final String ANDROID_KEY_STORE = "AndroidKeyStore";
+
+ private static final String KEY_ALIAS = "PaKanonKeyAttestation";
+
+ private KeyAttestation mKeyAttestation;
+
+ private KeyStore mSpyKeyStore;
+
+ private KeyPairGenerator mSpyKeyPairGenerator;
+
+ @Before
+ public void setUp() throws Exception {
+ mSpyKeyStore = spy(KeyStore.getInstance(ANDROID_KEY_STORE));
+ mSpyKeyPairGenerator =
+ spy(
+ KeyPairGenerator.getInstance(
+ KeyProperties.KEY_ALGORITHM_EC, ANDROID_KEY_STORE));
+ mKeyAttestation =
+ new KeyAttestation(/* useStrongBox= */ false, mSpyKeyStore, mSpyKeyPairGenerator);
+ }
+
+ @After
+ public void tearDown() throws Exception {
+ KeyStore keyStore = KeyStore.getInstance(ANDROID_KEY_STORE);
+ keyStore.load(null);
+ if (keyStore.containsAlias(KEY_ALIAS)) {
+ keyStore.deleteEntry(KEY_ALIAS);
+ }
+
+ Mockito.reset(mSpyKeyStore, mSpyKeyPairGenerator);
+ }
+
+ @Test
+ public void testGenerateAttestationRecord_success() throws Exception {
+ KeyAttestationCertificateChainRecord record =
+ mKeyAttestation.generateAttestationRecord(CHALLENGE);
+
+ assertThat(record.encode().length).isGreaterThan(4);
+ }
+
+ @Test
+ public void testGenerateAttestationRecord_nullKey_throwsException() {
+ doReturn(null).when(mSpyKeyPairGenerator).generateKeyPair();
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> mKeyAttestation.generateAttestationRecord(CHALLENGE));
+ }
+
+ @Test
+ public void testGenerateHybridKey_success() {
+ KeyPair keyPair = mKeyAttestation.generateHybridKey(CHALLENGE, KEY_ALIAS);
+
+ assertThat(keyPair).isNotNull();
+ assertThat(keyPair.getPublic()).isNotNull();
+ assertThat(keyPair.getPrivate()).isNotNull();
+ }
+
+ @Test
+ public void testGenerateHybridKey_initFailure() throws Exception {
+ doThrow(new InvalidAlgorithmParameterException("Invalid Parameters"))
+ .when(mSpyKeyPairGenerator)
+ .initialize(any());
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> mKeyAttestation.generateHybridKey(CHALLENGE, KEY_ALIAS));
+ }
+
+ @Test
+ public void testGetAttestationRecordFromKeyAlias_success() throws Exception {
+ KeyPair unused = mKeyAttestation.generateHybridKey(CHALLENGE, KEY_ALIAS);
+
+ KeyAttestationCertificateChainRecord record =
+ mKeyAttestation.getAttestationRecordFromKeyAlias(KEY_ALIAS);
+
+ assertThat(record.encode().length).isGreaterThan(4);
+ }
+
+ @Test
+ public void testGetAttestationRecordFromKeyAlias_certFailure() throws Exception {
+ doThrow(new CertificateException("Cert Exception")).when(mSpyKeyStore).load(any());
+
+ KeyAttestationCertificateChainRecord record =
+ mKeyAttestation.getAttestationRecordFromKeyAlias(KEY_ALIAS);
+
+ assertThat(record.encode().length).isEqualTo(0);
+ }
+
+ @Test
+ public void testGetAttestationRecordFromKeyAlias_keyStoreFailure() throws Exception {
+ doThrow(new KeyStoreException("Key Store Exception"))
+ .when(mSpyKeyStore)
+ .getCertificateChain(any());
+
+ KeyAttestationCertificateChainRecord record =
+ mKeyAttestation.getAttestationRecordFromKeyAlias(KEY_ALIAS);
+
+ assertThat(record.encode().length).isEqualTo(0);
+ }
+
+ @Test
+ public void testGetAttestationRecordFromKeyAlias_keyStoreReturnsNullChain_Failure()
+ throws Exception {
+ doReturn(null).when(mSpyKeyStore).getCertificateChain(any());
+
+ assertThrows(
+ IllegalStateException.class,
+ () -> mKeyAttestation.getAttestationRecordFromKeyAlias(KEY_ALIAS));
+ }
+}