diff --git a/core/java/android/util/apk/SourceStampVerificationResult.java b/core/java/android/util/apk/SourceStampVerificationResult.java new file mode 100644 index 0000000000000..2edaf623fb94e --- /dev/null +++ b/core/java/android/util/apk/SourceStampVerificationResult.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2020 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 android.util.apk; + +import android.annotation.Nullable; + +import java.security.cert.Certificate; + +/** + * A class encapsulating the result from the source stamp verifier + * + *

It indicates whether the source stamp is verified or not, and the source stamp certificate. + * + * @hide + */ +public final class SourceStampVerificationResult { + + private final boolean mPresent; + private final boolean mVerified; + private final Certificate mCertificate; + + private SourceStampVerificationResult( + boolean present, boolean verified, @Nullable Certificate certificate) { + this.mPresent = present; + this.mVerified = verified; + this.mCertificate = certificate; + } + + public boolean isPresent() { + return mPresent; + } + + public boolean isVerified() { + return mVerified; + } + + public Certificate getCertificate() { + return mCertificate; + } + + /** + * Create a non-present source stamp outcome. + * + * @return A non-present source stamp result. + */ + public static SourceStampVerificationResult notPresent() { + return new SourceStampVerificationResult( + /* present= */ false, /* verified= */ false, /* certificate= */ null); + } + + /** + * Create a verified source stamp outcome. + * + * @param certificate The source stamp certificate. + * @return A verified source stamp result, and the source stamp certificate. + */ + public static SourceStampVerificationResult verified(Certificate certificate) { + return new SourceStampVerificationResult( + /* present= */ true, /* verified= */ true, certificate); + } + + /** + * Create a non-verified source stamp outcome. + * + * @return A non-verified source stamp result. + */ + public static SourceStampVerificationResult notVerified() { + return new SourceStampVerificationResult( + /* present= */ true, /* verified= */ false, /* certificate= */ null); + } +} diff --git a/core/java/android/util/apk/SourceStampVerifier.java b/core/java/android/util/apk/SourceStampVerifier.java new file mode 100644 index 0000000000000..759c8649532bb --- /dev/null +++ b/core/java/android/util/apk/SourceStampVerifier.java @@ -0,0 +1,302 @@ +/* + * Copyright (C) 2020 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 android.util.apk; + +import static android.util.apk.ApkSigningBlockUtils.compareSignatureAlgorithm; +import static android.util.apk.ApkSigningBlockUtils.getLengthPrefixedSlice; +import static android.util.apk.ApkSigningBlockUtils.getSignatureAlgorithmContentDigestAlgorithm; +import static android.util.apk.ApkSigningBlockUtils.getSignatureAlgorithmJcaSignatureAlgorithm; +import static android.util.apk.ApkSigningBlockUtils.isSupportedSignatureAlgorithm; +import static android.util.apk.ApkSigningBlockUtils.readLengthPrefixedByteArray; + +import android.util.Pair; +import android.util.jar.StrictJarFile; + +import libcore.io.IoUtils; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.SignatureException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; + +/** + * Source Stamp verifier. + * + *

SourceStamp improves traceability of apps with respect to unauthorized distribution. + * + *

The stamp is part of the APK that is protected by the signing block. + * + *

The APK contents hash is signed using the stamp key, and is saved as part of the signing + * block. + * + * @hide for internal use only. + */ +public abstract class SourceStampVerifier { + + private static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a; + private static final int APK_SIGNATURE_SCHEME_V3_BLOCK_ID = 0xf05368c0; + private static final int SOURCE_STAMP_BLOCK_ID = 0x2b09189e; + + /** Name of the SourceStamp certificate hash ZIP entry in APKs. */ + private static final String SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME = "stamp-cert-sha256"; + + /** Hidden constructor to prevent instantiation. */ + private SourceStampVerifier() {} + + /** Verifies SourceStamp present in the provided APK. */ + public static SourceStampVerificationResult verify(String apkFile) { + try (RandomAccessFile apk = new RandomAccessFile(apkFile, "r")) { + return verify(apk); + } catch (Exception e) { + // Any exception in the SourceStamp verification returns a non-verified SourceStamp + // outcome without affecting the outcome of any of the other signature schemes. + return SourceStampVerificationResult.notVerified(); + } + } + + private static SourceStampVerificationResult verify(RandomAccessFile apk) + throws IOException, SignatureNotFoundException { + byte[] sourceStampCertificateDigest = getSourceStampCertificateDigest(apk); + if (sourceStampCertificateDigest == null) { + // SourceStamp certificate hash file not found, which means that there is not + // SourceStamp present. + return SourceStampVerificationResult.notPresent(); + } + SignatureInfo signatureInfo = + ApkSigningBlockUtils.findSignature(apk, SOURCE_STAMP_BLOCK_ID); + Map apkContentDigests = getApkContentDigests(apk); + return verify(signatureInfo, apkContentDigests, sourceStampCertificateDigest); + } + + private static SourceStampVerificationResult verify( + SignatureInfo signatureInfo, + Map apkContentDigests, + byte[] sourceStampCertificateDigest) + throws SecurityException, IOException { + CertificateFactory certFactory; + try { + certFactory = CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new RuntimeException("Failed to obtain X.509 CertificateFactory", e); + } + + List> digests = + apkContentDigests.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(e -> Pair.create(e.getKey(), e.getValue())) + .collect(Collectors.toList()); + byte[] digestBytes = encodeApkContentDigests(digests); + + ByteBuffer sourceStampBlock = signatureInfo.signatureBlock; + ByteBuffer sourceStampBlockData = + ApkSigningBlockUtils.getLengthPrefixedSlice(sourceStampBlock); + + // Parse the SourceStamp certificate. + byte[] sourceStampEncodedCertificate = + ApkSigningBlockUtils.readLengthPrefixedByteArray(sourceStampBlockData); + X509Certificate sourceStampCertificate; + try { + sourceStampCertificate = + (X509Certificate) + certFactory.generateCertificate( + new ByteArrayInputStream(sourceStampEncodedCertificate)); + } catch (CertificateException e) { + throw new SecurityException("Failed to decode certificate", e); + } + sourceStampCertificate = + new VerbatimX509Certificate(sourceStampCertificate, sourceStampEncodedCertificate); + + // Verify the SourceStamp certificate found in the signing block is the same as the + // SourceStamp certificate found in the APK. + try { + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + messageDigest.update(sourceStampEncodedCertificate); + byte[] sourceStampBlockCertificateDigest = messageDigest.digest(); + if (!Arrays.equals(sourceStampCertificateDigest, sourceStampBlockCertificateDigest)) { + throw new SecurityException("Certificate mismatch between APK and signature block"); + } + } catch (NoSuchAlgorithmException e) { + throw new SecurityException("Failed to find SHA-256", e); + } + + // Parse the signatures block and identify supported signatures + ByteBuffer signatures = ApkSigningBlockUtils.getLengthPrefixedSlice(sourceStampBlockData); + int signatureCount = 0; + int bestSigAlgorithm = -1; + byte[] bestSigAlgorithmSignatureBytes = null; + while (signatures.hasRemaining()) { + signatureCount++; + try { + ByteBuffer signature = getLengthPrefixedSlice(signatures); + if (signature.remaining() < 8) { + throw new SecurityException("Signature record too short"); + } + int sigAlgorithm = signature.getInt(); + if (!isSupportedSignatureAlgorithm(sigAlgorithm)) { + continue; + } + if ((bestSigAlgorithm == -1) + || (compareSignatureAlgorithm(sigAlgorithm, bestSigAlgorithm) > 0)) { + bestSigAlgorithm = sigAlgorithm; + bestSigAlgorithmSignatureBytes = readLengthPrefixedByteArray(signature); + } + } catch (IOException | BufferUnderflowException e) { + throw new SecurityException( + "Failed to parse signature record #" + signatureCount, e); + } + } + if (bestSigAlgorithm == -1) { + if (signatureCount == 0) { + throw new SecurityException("No signatures found"); + } else { + throw new SecurityException("No supported signatures found"); + } + } + + // Verify signatures over digests using the SourceStamp's certificate. + Pair signatureAlgorithmParams = + getSignatureAlgorithmJcaSignatureAlgorithm(bestSigAlgorithm); + String jcaSignatureAlgorithm = signatureAlgorithmParams.first; + AlgorithmParameterSpec jcaSignatureAlgorithmParams = signatureAlgorithmParams.second; + PublicKey publicKey = sourceStampCertificate.getPublicKey(); + boolean sigVerified; + try { + Signature sig = Signature.getInstance(jcaSignatureAlgorithm); + sig.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + sig.setParameter(jcaSignatureAlgorithmParams); + } + sig.update(digestBytes); + sigVerified = sig.verify(bestSigAlgorithmSignatureBytes); + } catch (InvalidKeyException + | InvalidAlgorithmParameterException + | SignatureException + | NoSuchAlgorithmException e) { + throw new SecurityException( + "Failed to verify " + jcaSignatureAlgorithm + " signature", e); + } + if (!sigVerified) { + throw new SecurityException(jcaSignatureAlgorithm + " signature did not verify"); + } + + return SourceStampVerificationResult.verified(sourceStampCertificate); + } + + private static Map getApkContentDigests(RandomAccessFile apk) + throws IOException, SignatureNotFoundException { + // Retrieve APK content digests in V3 signing block. If a V3 signature is not found, the APK + // content digests would be re-tried from V2 signature. + try { + SignatureInfo v3SignatureInfo = + ApkSigningBlockUtils.findSignature(apk, APK_SIGNATURE_SCHEME_V3_BLOCK_ID); + return getApkContentDigestsFromSignatureBlock(v3SignatureInfo.signatureBlock); + } catch (SignatureNotFoundException e) { + // It's fine not to find a V3 signature. + } + + // Retrieve APK content digests in V2 signing block. If a V2 signature is not found, the + // process of retrieving APK content digests stops, and the stamp is considered un-verified. + SignatureInfo v2SignatureInfo = + ApkSigningBlockUtils.findSignature(apk, APK_SIGNATURE_SCHEME_V2_BLOCK_ID); + return getApkContentDigestsFromSignatureBlock(v2SignatureInfo.signatureBlock); + } + + private static Map getApkContentDigestsFromSignatureBlock( + ByteBuffer signatureBlock) throws IOException { + Map apkContentDigests = new HashMap<>(); + ByteBuffer signers = getLengthPrefixedSlice(signatureBlock); + while (signers.hasRemaining()) { + ByteBuffer signer = getLengthPrefixedSlice(signers); + ByteBuffer signedData = getLengthPrefixedSlice(signer); + ByteBuffer digests = getLengthPrefixedSlice(signedData); + while (digests.hasRemaining()) { + ByteBuffer digest = getLengthPrefixedSlice(digests); + int sigAlgorithm = digest.getInt(); + byte[] contentDigest = readLengthPrefixedByteArray(digest); + int digestAlgorithm = getSignatureAlgorithmContentDigestAlgorithm(sigAlgorithm); + apkContentDigests.put(digestAlgorithm, contentDigest); + } + } + return apkContentDigests; + } + + private static byte[] getSourceStampCertificateDigest(RandomAccessFile apk) throws IOException { + StrictJarFile apkJar = + new StrictJarFile( + apk.getFD(), + /* verify= */ false, + /* signatureSchemeRollbackProtectionsEnforced= */ false); + ZipEntry zipEntry = apkJar.findEntry(SOURCE_STAMP_CERTIFICATE_HASH_ZIP_ENTRY_NAME); + if (zipEntry == null) { + // SourceStamp certificate hash file not found, which means that there is not + // SourceStamp present. + return null; + } + InputStream inputStream = null; + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + try { + inputStream = apkJar.getInputStream(zipEntry); + + // Trying to read the certificate digest, which should be less than 1024 bytes. + byte[] buffer = new byte[1024]; + int count = inputStream.read(buffer, 0, buffer.length); + byteArrayOutputStream.write(buffer, 0, count); + + return byteArrayOutputStream.toByteArray(); + } finally { + IoUtils.closeQuietly(inputStream); + } + } + + private static byte[] encodeApkContentDigests(List> apkContentDigests) { + int resultSize = 0; + for (Pair element : apkContentDigests) { + resultSize += 12 + element.second.length; + } + ByteBuffer result = ByteBuffer.allocate(resultSize); + result.order(ByteOrder.LITTLE_ENDIAN); + for (Pair element : apkContentDigests) { + byte[] second = element.second; + result.putInt(8 + second.length); + result.putInt(element.first); + result.putInt(second.length); + result.put(second); + } + return result.array(); + } +} diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/original.apk b/core/tests/coretests/assets/SourceStampVerifierTest/original.apk new file mode 100644 index 0000000000000..05f89aa80e11f Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/original.apk differ diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/stamp-apk-hash-mismatch.apk b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-apk-hash-mismatch.apk new file mode 100644 index 0000000000000..1dc1e998f1eec Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-apk-hash-mismatch.apk differ diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/stamp-certificate-mismatch.apk b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-certificate-mismatch.apk new file mode 100644 index 0000000000000..562805cf67eb0 Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-certificate-mismatch.apk differ diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/stamp-malformed-signature.apk b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-malformed-signature.apk new file mode 100644 index 0000000000000..2723cc8fdbeb5 Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-malformed-signature.apk differ diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/stamp-without-block.apk b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-without-block.apk new file mode 100644 index 0000000000000..9dec2f5911334 Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/stamp-without-block.apk differ diff --git a/core/tests/coretests/assets/SourceStampVerifierTest/valid-stamp.apk b/core/tests/coretests/assets/SourceStampVerifierTest/valid-stamp.apk new file mode 100644 index 0000000000000..8056e0bf6e50d Binary files /dev/null and b/core/tests/coretests/assets/SourceStampVerifierTest/valid-stamp.apk differ diff --git a/core/tests/coretests/src/android/util/apk/SourceStampVerifierTest.java b/core/tests/coretests/src/android/util/apk/SourceStampVerifierTest.java new file mode 100644 index 0000000000000..44f64079d8313 --- /dev/null +++ b/core/tests/coretests/src/android/util/apk/SourceStampVerifierTest.java @@ -0,0 +1,139 @@ +/* + * Copyright (C) 2020 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 android.util.apk; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import android.content.Context; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.security.MessageDigest; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/** Unit test for {@link android.util.apk.SourceStampVerifier} */ +@RunWith(JUnit4.class) +public class SourceStampVerifierTest { + + private final Context mContext = + InstrumentationRegistry.getInstrumentation().getTargetContext(); + + @Test + public void testSourceStamp_noStamp() throws Exception { + File testApk = getApk("SourceStampVerifierTest/original.apk"); + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertFalse(result.isPresent()); + assertFalse(result.isVerified()); + assertNull(result.getCertificate()); + } + + @Test + public void testSourceStamp_correctSignature() throws Exception { + File testApk = getApk("SourceStampVerifierTest/valid-stamp.apk"); + ZipFile apkZipFile = new ZipFile(testApk); + ZipEntry stampCertZipEntry = apkZipFile.getEntry("stamp-cert-sha256"); + int size = (int) stampCertZipEntry.getSize(); + byte[] expectedStampCertHash = new byte[size]; + try (InputStream inputStream = apkZipFile.getInputStream(stampCertZipEntry)) { + inputStream.read(expectedStampCertHash); + } + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertTrue(result.isPresent()); + assertTrue(result.isVerified()); + assertNotNull(result.getCertificate()); + byte[] actualStampCertHash = + MessageDigest.getInstance("SHA-256").digest(result.getCertificate().getEncoded()); + assertArrayEquals(expectedStampCertHash, actualStampCertHash); + } + + @Test + public void testSourceStamp_signatureMissing() throws Exception { + File testApk = getApk("SourceStampVerifierTest/stamp-without-block.apk"); + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertTrue(result.isPresent()); + assertFalse(result.isVerified()); + assertNull(result.getCertificate()); + } + + @Test + public void testSourceStamp_certificateMismatch() throws Exception { + File testApk = getApk("SourceStampVerifierTest/stamp-certificate-mismatch.apk"); + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertTrue(result.isPresent()); + assertFalse(result.isVerified()); + assertNull(result.getCertificate()); + } + + @Test + public void testSourceStamp_apkHashMismatch() throws Exception { + File testApk = getApk("SourceStampVerifierTest/stamp-apk-hash-mismatch.apk"); + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertTrue(result.isPresent()); + assertFalse(result.isVerified()); + assertNull(result.getCertificate()); + } + + @Test + public void testSourceStamp_malformedSignature() throws Exception { + File testApk = getApk("SourceStampVerifierTest/stamp-malformed-signature.apk"); + + SourceStampVerificationResult result = + SourceStampVerifier.verify(testApk.getAbsolutePath()); + + assertTrue(result.isPresent()); + assertFalse(result.isVerified()); + assertNull(result.getCertificate()); + } + + private File getApk(String apkPath) throws IOException { + File testApk = File.createTempFile("SourceStampApk", ".apk"); + try (InputStream inputStream = mContext.getAssets().open(apkPath)) { + Files.copy(inputStream, testApk.toPath(), REPLACE_EXISTING); + } + return testApk; + } +}