| /* |
| * Copyright (C) 2017 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.apksig; |
| |
| import static org.junit.Assert.fail; |
| |
| import com.android.apksig.ApkVerifier.Issue; |
| import com.android.apksig.apk.ApkFormatException; |
| import com.android.apksig.internal.util.Resources; |
| import com.android.apksig.util.DataSinks; |
| import com.android.apksig.util.DataSource; |
| import com.android.apksig.util.DataSources; |
| import com.android.apksig.util.ReadableDataSink; |
| import java.io.File; |
| import java.io.IOException; |
| import java.nio.ByteBuffer; |
| import java.nio.channels.ByteChannel; |
| import java.nio.file.Files; |
| import java.nio.file.StandardOpenOption; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.PrivateKey; |
| import java.security.cert.X509Certificate; |
| import java.util.Collections; |
| import java.util.List; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.junit.runners.JUnit4; |
| |
| @RunWith(JUnit4.class) |
| public class ApkSignerTest { |
| |
| /** |
| * Whether to preserve, as files, outputs of failed tests. This is useful for investigating test |
| * failures. |
| */ |
| private static final boolean KEEP_FAILING_OUTPUT_AS_FILES = false; |
| |
| public static void main(String[] params) throws Exception { |
| File outDir = (params.length > 0) ? new File(params[0]) : new File("."); |
| generateGoldenFiles(outDir); |
| } |
| |
| private static void generateGoldenFiles(File outDir) throws Exception { |
| System.out.println( |
| "Generating golden files " + ApkSignerTest.class.getSimpleName() |
| + " into " + outDir); |
| if (!outDir.mkdirs()) { |
| throw new IOException("Failed to create directory: " + outDir); |
| } |
| List<ApkSigner.SignerConfig> rsa2048SignerConfig = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| |
| signGolden( |
| "golden-unaligned-in.apk", |
| new File(outDir, "golden-unaligned-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| signGolden( |
| "golden-legacy-aligned-in.apk", |
| new File(outDir, "golden-legacy-aligned-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| signGolden( |
| "golden-aligned-in.apk", |
| new File(outDir, "golden-aligned-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| |
| signGolden( |
| "golden-unaligned-in.apk", |
| new File(outDir, "golden-unaligned-v1-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| signGolden( |
| "golden-legacy-aligned-in.apk", |
| new File(outDir, "golden-legacy-aligned-v1-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| signGolden( |
| "golden-aligned-in.apk", |
| new File(outDir, "golden-aligned-v1-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| |
| signGolden( |
| "golden-unaligned-in.apk", |
| new File(outDir, "golden-unaligned-v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| signGolden( |
| "golden-legacy-aligned-in.apk", |
| new File(outDir, "golden-legacy-aligned-v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| signGolden( |
| "golden-aligned-in.apk", |
| new File(outDir, "golden-aligned-v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| |
| signGolden( |
| "golden-unaligned-in.apk", |
| new File(outDir, "golden-unaligned-v1v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| signGolden( |
| "golden-legacy-aligned-in.apk", |
| new File(outDir, "golden-legacy-aligned-v1v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| signGolden( |
| "golden-aligned-in.apk", |
| new File(outDir, "golden-aligned-v1v2-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| |
| |
| signGolden( |
| "original.apk", new File(outDir, "golden-rsa-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| signGolden( |
| "original.apk", new File(outDir, "golden-rsa-minSdkVersion-1-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig).setMinSdkVersion(1)); |
| signGolden( |
| "original.apk", new File(outDir, "golden-rsa-minSdkVersion-18-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig).setMinSdkVersion(18)); |
| signGolden( |
| "original.apk", new File(outDir, "golden-rsa-minSdkVersion-24-out.apk"), |
| new ApkSigner.Builder(rsa2048SignerConfig).setMinSdkVersion(24)); |
| } |
| |
| private static void signGolden( |
| String inResourceName, File outFile, ApkSigner.Builder apkSignerBuilder) |
| throws Exception { |
| DataSource in = |
| DataSources.asDataSource( |
| ByteBuffer.wrap(Resources.toByteArray(ApkSigner.class, inResourceName))); |
| apkSignerBuilder |
| .setInputApk(in) |
| .setOutputApk(outFile) |
| .build() |
| .sign(); |
| } |
| |
| @Test |
| public void testAlignmentPreserved_Golden() throws Exception { |
| // Regression tests for preserving (mis)alignment of ZIP Local File Header data |
| // NOTE: Expected output files can be re-generated by running the "main" method. |
| |
| List<ApkSigner.SignerConfig> rsa2048SignerConfig = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| |
| // Uncompressed entries in this input file are not aligned -- the file was created using |
| // the jar utility. temp4.txt entry was then manually added into the archive. This entry's |
| // ZIP Local File Header "extra" field declares that the entry's data must be aligned to |
| // 4 kB boundary, but the data isn't actually aligned in the file. |
| assertGolden( |
| "golden-unaligned-in.apk", "golden-unaligned-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| assertGolden( |
| "golden-unaligned-in.apk", "golden-unaligned-v1-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| assertGolden( |
| "golden-unaligned-in.apk", "golden-unaligned-v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| assertGolden( |
| "golden-unaligned-in.apk", "golden-unaligned-v1v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| |
| // Uncompressed entries in this input file are aligned by zero-padding the "extra" field, as |
| // performed by zipalign at the time of writing. This padding technique produces ZIP |
| // archives whose "extra" field are not compliant with APPNOTE.TXT. Hence, this technique |
| // was deprecated. |
| assertGolden( |
| "golden-legacy-aligned-in.apk", "golden-legacy-aligned-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| assertGolden( |
| "golden-legacy-aligned-in.apk", "golden-legacy-aligned-v1-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| assertGolden( |
| "golden-legacy-aligned-in.apk", "golden-legacy-aligned-v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| assertGolden( |
| "golden-legacy-aligned-in.apk", "golden-legacy-aligned-v1v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| |
| // Uncompressed entries in this input file are aligned by padding the "extra" field, as |
| // generated by signapk and apksigner. This padding technique produces "extra" fields which |
| // are compliant with APPNOTE.TXT. |
| assertGolden( |
| "golden-aligned-in.apk", "golden-aligned-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig)); |
| assertGolden( |
| "golden-aligned-in.apk", "golden-aligned-v1-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(false)); |
| assertGolden( |
| "golden-aligned-in.apk", "golden-aligned-v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(false) |
| .setV2SigningEnabled(true)); |
| assertGolden( |
| "golden-aligned-in.apk", "golden-aligned-v1v2-out.apk", |
| new ApkSigner.Builder(rsa2048SignerConfig) |
| .setV1SigningEnabled(true) |
| .setV2SigningEnabled(true)); |
| } |
| |
| @Test |
| public void testMinSdkVersion_Golden() throws Exception { |
| // Regression tests for minSdkVersion-based signature/digest algorithm selection |
| // NOTE: Expected output files can be re-generated by running the "main" method. |
| |
| List<ApkSigner.SignerConfig> rsaSignerConfig = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| assertGolden("original.apk", "golden-rsa-out.apk", new ApkSigner.Builder(rsaSignerConfig)); |
| assertGolden( |
| "original.apk", "golden-rsa-minSdkVersion-1-out.apk", |
| new ApkSigner.Builder(rsaSignerConfig).setMinSdkVersion(1)); |
| assertGolden( |
| "original.apk", "golden-rsa-minSdkVersion-18-out.apk", |
| new ApkSigner.Builder(rsaSignerConfig).setMinSdkVersion(18)); |
| assertGolden( |
| "original.apk", "golden-rsa-minSdkVersion-24-out.apk", |
| new ApkSigner.Builder(rsaSignerConfig).setMinSdkVersion(24)); |
| |
| // TODO: Add tests for DSA and ECDSA. This is non-trivial because the default |
| // implementations of these signature algorithms are non-deterministic which means output |
| // files always differ from golden files. |
| } |
| |
| @Test |
| public void testRsaSignedVerifies() throws Exception { |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| String in = "original.apk"; |
| |
| // Sign so that the APK is guaranteed to verify on API Level 1+ |
| DataSource out = sign(in, new ApkSigner.Builder(signers).setMinSdkVersion(1)); |
| assertVerified(verifyForMinSdkVersion(out, 1)); |
| |
| // Sign so that the APK is guaranteed to verify on API Level 18+ |
| out = sign(in, new ApkSigner.Builder(signers).setMinSdkVersion(18)); |
| assertVerified(verifyForMinSdkVersion(out, 18)); |
| // Does not verify on API Level 17 because RSA with SHA-256 not supported |
| assertVerificationFailure( |
| verifyForMinSdkVersion(out, 17), Issue.JAR_SIG_UNSUPPORTED_SIG_ALG); |
| } |
| |
| @Test |
| public void testDsaSignedVerifies() throws Exception { |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("dsa-1024")); |
| String in = "original.apk"; |
| |
| // Sign so that the APK is guaranteed to verify on API Level 1+ |
| DataSource out = sign(in, new ApkSigner.Builder(signers).setMinSdkVersion(1)); |
| assertVerified(verifyForMinSdkVersion(out, 1)); |
| |
| // Sign so that the APK is guaranteed to verify on API Level 21+ |
| out = sign(in, new ApkSigner.Builder(signers).setMinSdkVersion(21)); |
| assertVerified(verifyForMinSdkVersion(out, 21)); |
| // Does not verify on API Level 20 because DSA with SHA-256 not supported |
| assertVerificationFailure( |
| verifyForMinSdkVersion(out, 20), Issue.JAR_SIG_UNSUPPORTED_SIG_ALG); |
| } |
| |
| @Test |
| public void testEcSignedVerifies() throws Exception { |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("ec-p256")); |
| String in = "original.apk"; |
| |
| // NOTE: EC APK signatures are not supported prior to API Level 18 |
| // Sign so that the APK is guaranteed to verify on API Level 18+ |
| DataSource out = sign(in, new ApkSigner.Builder(signers).setMinSdkVersion(18)); |
| assertVerified(verifyForMinSdkVersion(out, 18)); |
| // Does not verify on API Level 17 because EC not supported |
| assertVerificationFailure( |
| verifyForMinSdkVersion(out, 17), Issue.JAR_SIG_UNSUPPORTED_SIG_ALG); |
| } |
| |
| @Test |
| public void testV1SigningRejectsInvalidZipEntryNames() throws Exception { |
| // ZIP/JAR entry name cannot contain CR, LF, or NUL characters when the APK is being |
| // JAR-signed. |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| try { |
| sign("v1-only-with-cr-in-entry-name.apk", |
| new ApkSigner.Builder(signers).setV1SigningEnabled(true)); |
| fail(); |
| } catch (ApkFormatException expected) {} |
| |
| try { |
| sign("v1-only-with-lf-in-entry-name.apk", |
| new ApkSigner.Builder(signers).setV1SigningEnabled(true)); |
| fail(); |
| } catch (ApkFormatException expected) {} |
| |
| try { |
| sign("v1-only-with-nul-in-entry-name.apk", |
| new ApkSigner.Builder(signers).setV1SigningEnabled(true)); |
| fail(); |
| } catch (ApkFormatException expected) {} |
| } |
| |
| @Test |
| public void testWeirdZipCompressionMethod() throws Exception { |
| // Any ZIP compression method other than STORED is treated as DEFLATED by Android. |
| // This APK declares compression method 21 (neither STORED nor DEFLATED) for CERT.RSA entry, |
| // but the entry is actually Deflate-compressed. |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| sign("weird-compression-method.apk", new ApkSigner.Builder(signers)); |
| } |
| |
| @Test |
| public void testZipCompressionMethodMismatchBetweenLfhAndCd() throws Exception { |
| // Android Package Manager ignores compressionMethod field in Local File Header and always |
| // uses the compressionMethod from Central Directory instead. |
| // In this APK, compression method of CERT.RSA is declared as STORED in Local File Header |
| // and as DEFLATED in Central Directory. The entry is actually Deflate-compressed. |
| List<ApkSigner.SignerConfig> signers = |
| Collections.singletonList(getDefaultSignerConfigFromResources("rsa-2048")); |
| sign("mismatched-compression-method.apk", new ApkSigner.Builder(signers)); |
| } |
| |
| /** |
| * Asserts that signing the specified golden input file using the provided signing |
| * configuration produces output identical to the specified golden output file. |
| */ |
| private void assertGolden( |
| String inResourceName, String expectedOutResourceName, |
| ApkSigner.Builder apkSignerBuilder) throws Exception { |
| // Sign the provided golden input |
| DataSource out = sign(inResourceName, apkSignerBuilder); |
| |
| // Assert that the output is identical to the provided golden output |
| if (out.size() > Integer.MAX_VALUE) { |
| throw new RuntimeException("Output too large: " + out.size() + " bytes"); |
| } |
| ByteBuffer actualOutBuf = out.getByteBuffer(0, (int) out.size()); |
| |
| ByteBuffer expectedOutBuf = |
| ByteBuffer.wrap(Resources.toByteArray(getClass(), expectedOutResourceName)); |
| |
| int actualStartPos = actualOutBuf.position(); |
| boolean identical = false; |
| if (actualOutBuf.remaining() == expectedOutBuf.remaining()) { |
| while (actualOutBuf.hasRemaining()) { |
| if (actualOutBuf.get() != expectedOutBuf.get()) { |
| break; |
| } |
| } |
| identical = !actualOutBuf.hasRemaining(); |
| } |
| |
| if (identical) { |
| return; |
| } |
| actualOutBuf.position(actualStartPos); |
| |
| if (KEEP_FAILING_OUTPUT_AS_FILES) { |
| File tmp = File.createTempFile(getClass().getSimpleName(), ".apk"); |
| try (ByteChannel outChannel = |
| Files.newByteChannel( |
| tmp.toPath(), |
| StandardOpenOption.WRITE, |
| StandardOpenOption.CREATE, |
| StandardOpenOption.TRUNCATE_EXISTING)) { |
| while (actualOutBuf.hasRemaining()) { |
| outChannel.write(actualOutBuf); |
| } |
| } |
| fail(tmp + " differs from " + expectedOutResourceName); |
| } else { |
| fail("Output differs from " + expectedOutResourceName); |
| } |
| } |
| |
| private DataSource sign( |
| String inResourceName, ApkSigner.Builder apkSignerBuilder) throws Exception { |
| DataSource in = |
| DataSources.asDataSource( |
| ByteBuffer.wrap(Resources.toByteArray(getClass(), inResourceName))); |
| ReadableDataSink out = DataSinks.newInMemoryDataSink(); |
| apkSignerBuilder |
| .setInputApk(in) |
| .setOutputApk(out) |
| .build() |
| .sign(); |
| return out; |
| } |
| |
| private static ApkVerifier.Result verifyForMinSdkVersion(DataSource apk, int minSdkVersion) |
| throws IOException, ApkFormatException, NoSuchAlgorithmException { |
| return verify(apk, minSdkVersion); |
| } |
| |
| private static ApkVerifier.Result verify(DataSource apk, Integer minSdkVersionOverride) |
| throws IOException, ApkFormatException, NoSuchAlgorithmException { |
| ApkVerifier.Builder builder = new ApkVerifier.Builder(apk); |
| if (minSdkVersionOverride != null) { |
| builder.setMinCheckedPlatformVersion(minSdkVersionOverride); |
| } |
| return builder.build().verify(); |
| } |
| |
| private static void assertVerified(ApkVerifier.Result result) { |
| ApkVerifierTest.assertVerified(result); |
| } |
| |
| private static void assertVerificationFailure(ApkVerifier.Result result, Issue expectedIssue) { |
| ApkVerifierTest.assertVerificationFailure(result, expectedIssue); |
| } |
| |
| private static ApkSigner.SignerConfig getDefaultSignerConfigFromResources( |
| String keyNameInResources) throws Exception { |
| PrivateKey privateKey = |
| Resources.toPrivateKey(ApkSignerTest.class, keyNameInResources + ".pk8"); |
| List<X509Certificate> certs = |
| Resources.toCertificateChain(ApkSignerTest.class, keyNameInResources + ".x509.pem"); |
| return new ApkSigner.SignerConfig.Builder(keyNameInResources, privateKey, certs).build(); |
| } |
| } |