/* * Copyright (C) 2016 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.internal.apk.v1; import com.android.apksig.apk.ApkFormatException; import com.android.apksig.internal.jar.ManifestWriter; import com.android.apksig.internal.jar.SignatureFileWriter; import com.android.apksig.internal.util.Pair; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.PrivateKey; import java.security.PublicKey; import java.security.Signature; import java.security.SignatureException; import java.security.cert.CertificateException; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.SortedMap; import java.util.TreeMap; import java.util.jar.Attributes; import java.util.jar.Manifest; import sun.security.pkcs.ContentInfo; import sun.security.pkcs.PKCS7; import sun.security.pkcs.SignerInfo; import sun.security.x509.AlgorithmId; import sun.security.x509.X500Name; /** * APK signer which uses JAR signing (aka v1 signing scheme). * * @see <a href="https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File">Signed JAR File</a> */ public abstract class V1SchemeSigner { public static final String MANIFEST_ENTRY_NAME = "META-INF/MANIFEST.MF"; private static final Attributes.Name ATTRIBUTE_NAME_CREATED_BY = new Attributes.Name("Created-By"); private static final String ATTRIBUTE_VALUE_MANIFEST_VERSION = "1.0"; private static final String ATTRIBUTE_VALUE_SIGNATURE_VERSION = "1.0"; static final String SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME_STR = "X-Android-APK-Signed"; private static final Attributes.Name SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME = new Attributes.Name(SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME_STR); /** * Signer configuration. */ public static class SignerConfig { /** Name. */ public String name; /** Private key. */ public PrivateKey privateKey; /** * Certificates, with the first certificate containing the public key corresponding to * {@link #privateKey}. */ public List<X509Certificate> certificates; /** * Digest algorithm used for the signature. */ public DigestAlgorithm signatureDigestAlgorithm; } /** Hidden constructor to prevent instantiation. */ private V1SchemeSigner() {} /** * Gets the JAR signing digest algorithm to be used for signing an APK using the provided key. * * @param minSdkVersion minimum API Level of the platform on which the APK may be installed (see * AndroidManifest.xml minSdkVersion attribute) * * @throws InvalidKeyException if the provided key is not suitable for signing APKs using * JAR signing (aka v1 signature scheme) */ public static DigestAlgorithm getSuggestedSignatureDigestAlgorithm( PublicKey signingKey, int minSdkVersion) throws InvalidKeyException { String keyAlgorithm = signingKey.getAlgorithm(); if ("RSA".equalsIgnoreCase(keyAlgorithm)) { // Prior to API Level 18, only SHA-1 can be used with RSA. if (minSdkVersion < 18) { return DigestAlgorithm.SHA1; } return DigestAlgorithm.SHA256; } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { // Prior to API Level 21, only SHA-1 can be used with DSA if (minSdkVersion < 21) { return DigestAlgorithm.SHA1; } else { return DigestAlgorithm.SHA256; } } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { if (minSdkVersion < 18) { throw new InvalidKeyException( "ECDSA signatures only supported for minSdkVersion 18 and higher"); } return DigestAlgorithm.SHA256; } else { throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); } } /** * Returns a safe version of the provided signer name. */ public static String getSafeSignerName(String name) { if (name.isEmpty()) { throw new IllegalArgumentException("Empty name"); } // According to https://docs.oracle.com/javase/tutorial/deployment/jar/signing.html, the // name must not be longer than 8 characters and may contain only A-Z, 0-9, _, and -. StringBuilder result = new StringBuilder(); char[] nameCharsUpperCase = name.toUpperCase(Locale.US).toCharArray(); for (int i = 0; i < Math.min(nameCharsUpperCase.length, 8); i++) { char c = nameCharsUpperCase[i]; if (((c >= 'A') && (c <= 'Z')) || ((c >= '0') && (c <= '9')) || (c == '-') || (c == '_')) { result.append(c); } else { result.append('_'); } } return result.toString(); } /** * Returns a new {@link MessageDigest} instance corresponding to the provided digest algorithm. */ private static MessageDigest getMessageDigestInstance(DigestAlgorithm digestAlgorithm) throws NoSuchAlgorithmException { String jcaAlgorithm = digestAlgorithm.getJcaMessageDigestAlgorithm(); return MessageDigest.getInstance(jcaAlgorithm); } /** * Returns the JCA {@link MessageDigest} algorithm corresponding to the provided digest * algorithm. */ public static String getJcaMessageDigestAlgorithm(DigestAlgorithm digestAlgorithm) { return digestAlgorithm.getJcaMessageDigestAlgorithm(); } /** * Returns {@code true} if the provided JAR entry must be mentioned in signed JAR archive's * manifest. */ public static boolean isJarEntryDigestNeededInManifest(String entryName) { // See https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.html#Signed_JAR_File // Entries which represent directories sould not be listed in the manifest. if (entryName.endsWith("/")) { return false; } // Entries outside of META-INF must be listed in the manifest. if (!entryName.startsWith("META-INF/")) { return true; } // Entries in subdirectories of META-INF must be listed in the manifest. if (entryName.indexOf('/', "META-INF/".length()) != -1) { return true; } // Ignored file names (case-insensitive) in META-INF directory: // MANIFEST.MF // *.SF // *.RSA // *.DSA // *.EC // SIG-* String fileNameLowerCase = entryName.substring("META-INF/".length()).toLowerCase(Locale.US); if (("manifest.mf".equals(fileNameLowerCase)) || (fileNameLowerCase.endsWith(".sf")) || (fileNameLowerCase.endsWith(".rsa")) || (fileNameLowerCase.endsWith(".dsa")) || (fileNameLowerCase.endsWith(".ec")) || (fileNameLowerCase.startsWith("sig-"))) { return false; } return true; } /** * Signs the provided APK using JAR signing (aka v1 signature scheme) and returns the list of * JAR entries which need to be added to the APK as part of the signature. * * @param signerConfigs signer configurations, one for each signer. At least one signer config * must be provided. * * @throws ApkFormatException if the source manifest is malformed * @throws NoSuchAlgorithmException if a required cryptographic algorithm implementation is * missing * @throws InvalidKeyException if a signing key is not suitable for this signature scheme or * cannot be used in general * @throws SignatureException if an error occurs when computing digests of generating * signatures */ public static List<Pair<String, byte[]>> sign( List<SignerConfig> signerConfigs, DigestAlgorithm jarEntryDigestAlgorithm, Map<String, byte[]> jarEntryDigests, List<Integer> apkSigningSchemeIds, byte[] sourceManifestBytes, String createdBy) throws NoSuchAlgorithmException, ApkFormatException, InvalidKeyException, CertificateException, SignatureException { if (signerConfigs.isEmpty()) { throw new IllegalArgumentException("At least one signer config must be provided"); } OutputManifestFile manifest = generateManifestFile( jarEntryDigestAlgorithm, jarEntryDigests, sourceManifestBytes); return signManifest( signerConfigs, jarEntryDigestAlgorithm, apkSigningSchemeIds, createdBy, manifest); } /** * Signs the provided APK using JAR signing (aka v1 signature scheme) and returns the list of * JAR entries which need to be added to the APK as part of the signature. * * @param signerConfigs signer configurations, one for each signer. At least one signer config * must be provided. * * @throws InvalidKeyException if a signing key is not suitable for this signature scheme or * cannot be used in general * @throws SignatureException if an error occurs when computing digests of generating * signatures */ public static List<Pair<String, byte[]>> signManifest( List<SignerConfig> signerConfigs, DigestAlgorithm digestAlgorithm, List<Integer> apkSigningSchemeIds, String createdBy, OutputManifestFile manifest) throws NoSuchAlgorithmException, InvalidKeyException, CertificateException, SignatureException { if (signerConfigs.isEmpty()) { throw new IllegalArgumentException("At least one signer config must be provided"); } // For each signer output .SF and .(RSA|DSA|EC) file, then output MANIFEST.MF. List<Pair<String, byte[]>> signatureJarEntries = new ArrayList<>(2 * signerConfigs.size() + 1); byte[] sfBytes = generateSignatureFile(apkSigningSchemeIds, digestAlgorithm, createdBy, manifest); for (SignerConfig signerConfig : signerConfigs) { String signerName = signerConfig.name; byte[] signatureBlock; try { signatureBlock = generateSignatureBlock(signerConfig, sfBytes); } catch (InvalidKeyException e) { throw new InvalidKeyException( "Failed to sign using signer \"" + signerName + "\"", e); } catch (CertificateException e) { throw new CertificateException( "Failed to sign using signer \"" + signerName + "\"", e); } catch (SignatureException e) { throw new SignatureException( "Failed to sign using signer \"" + signerName + "\"", e); } signatureJarEntries.add(Pair.of("META-INF/" + signerName + ".SF", sfBytes)); PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); String signatureBlockFileName = "META-INF/" + signerName + "." + publicKey.getAlgorithm().toUpperCase(Locale.US); signatureJarEntries.add( Pair.of(signatureBlockFileName, signatureBlock)); } signatureJarEntries.add(Pair.of(MANIFEST_ENTRY_NAME, manifest.contents)); return signatureJarEntries; } /** * Returns the names of JAR entries which this signer will produce as part of v1 signature. */ public static Set<String> getOutputEntryNames(List<SignerConfig> signerConfigs) { Set<String> result = new HashSet<>(2 * signerConfigs.size() + 1); for (SignerConfig signerConfig : signerConfigs) { String signerName = signerConfig.name; result.add("META-INF/" + signerName + ".SF"); PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); String signatureBlockFileName = "META-INF/" + signerName + "." + publicKey.getAlgorithm().toUpperCase(Locale.US); result.add(signatureBlockFileName); } result.add(MANIFEST_ENTRY_NAME); return result; } /** * Generated and returns the {@code META-INF/MANIFEST.MF} file based on the provided (optional) * input {@code MANIFEST.MF} and digests of JAR entries covered by the manifest. */ public static OutputManifestFile generateManifestFile( DigestAlgorithm jarEntryDigestAlgorithm, Map<String, byte[]> jarEntryDigests, byte[] sourceManifestBytes) throws ApkFormatException { Manifest sourceManifest = null; if (sourceManifestBytes != null) { try { sourceManifest = new Manifest(new ByteArrayInputStream(sourceManifestBytes)); } catch (IOException e) { throw new ApkFormatException("Malformed source META-INF/MANIFEST.MF", e); } } ByteArrayOutputStream manifestOut = new ByteArrayOutputStream(); Attributes mainAttrs = new Attributes(); // Copy the main section from the source manifest (if provided). Otherwise use defaults. // NOTE: We don't output our own Created-By header because this signer did not create the // JAR/APK being signed -- the signer only adds signatures to the already existing // JAR/APK. if (sourceManifest != null) { mainAttrs.putAll(sourceManifest.getMainAttributes()); } else { mainAttrs.put(Attributes.Name.MANIFEST_VERSION, ATTRIBUTE_VALUE_MANIFEST_VERSION); } try { ManifestWriter.writeMainSection(manifestOut, mainAttrs); } catch (IOException e) { throw new RuntimeException("Failed to write in-memory MANIFEST.MF", e); } List<String> sortedEntryNames = new ArrayList<>(jarEntryDigests.keySet()); Collections.sort(sortedEntryNames); SortedMap<String, byte[]> invidualSectionsContents = new TreeMap<>(); String entryDigestAttributeName = getEntryDigestAttributeName(jarEntryDigestAlgorithm); for (String entryName : sortedEntryNames) { byte[] entryDigest = jarEntryDigests.get(entryName); Attributes entryAttrs = new Attributes(); entryAttrs.putValue( entryDigestAttributeName, Base64.getEncoder().encodeToString(entryDigest)); ByteArrayOutputStream sectionOut = new ByteArrayOutputStream(); byte[] sectionBytes; try { ManifestWriter.writeIndividualSection(sectionOut, entryName, entryAttrs); sectionBytes = sectionOut.toByteArray(); manifestOut.write(sectionBytes); } catch (IOException e) { throw new RuntimeException("Failed to write in-memory MANIFEST.MF", e); } invidualSectionsContents.put(entryName, sectionBytes); } OutputManifestFile result = new OutputManifestFile(); result.contents = manifestOut.toByteArray(); result.mainSectionAttributes = mainAttrs; result.individualSectionsContents = invidualSectionsContents; return result; } public static class OutputManifestFile { public byte[] contents; public SortedMap<String, byte[]> individualSectionsContents; public Attributes mainSectionAttributes; } private static byte[] generateSignatureFile( List<Integer> apkSignatureSchemeIds, DigestAlgorithm manifestDigestAlgorithm, String createdBy, OutputManifestFile manifest) throws NoSuchAlgorithmException { Manifest sf = new Manifest(); Attributes mainAttrs = sf.getMainAttributes(); mainAttrs.put(Attributes.Name.SIGNATURE_VERSION, ATTRIBUTE_VALUE_SIGNATURE_VERSION); mainAttrs.put(ATTRIBUTE_NAME_CREATED_BY, createdBy); if (!apkSignatureSchemeIds.isEmpty()) { // Add APK Signature Scheme v2 (and newer) signature stripping protection. // This attribute indicates that this APK is supposed to have been signed using one or // more APK-specific signature schemes in addition to the standard JAR signature scheme // used by this code. APK signature verifier should reject the APK if it does not // contain a signature for the signature scheme the verifier prefers out of this set. StringBuilder attrValue = new StringBuilder(); for (int id : apkSignatureSchemeIds) { if (attrValue.length() > 0) { attrValue.append(", "); } attrValue.append(String.valueOf(id)); } mainAttrs.put( SF_ATTRIBUTE_NAME_ANDROID_APK_SIGNED_NAME, attrValue.toString()); } // Add main attribute containing the digest of MANIFEST.MF. MessageDigest md = getMessageDigestInstance(manifestDigestAlgorithm); mainAttrs.putValue( getManifestDigestAttributeName(manifestDigestAlgorithm), Base64.getEncoder().encodeToString(md.digest(manifest.contents))); ByteArrayOutputStream out = new ByteArrayOutputStream(); try { SignatureFileWriter.writeMainSection(out, mainAttrs); } catch (IOException e) { throw new RuntimeException("Failed to write in-memory .SF file", e); } String entryDigestAttributeName = getEntryDigestAttributeName(manifestDigestAlgorithm); for (Map.Entry<String, byte[]> manifestSection : manifest.individualSectionsContents.entrySet()) { String sectionName = manifestSection.getKey(); byte[] sectionContents = manifestSection.getValue(); byte[] sectionDigest = md.digest(sectionContents); Attributes attrs = new Attributes(); attrs.putValue( entryDigestAttributeName, Base64.getEncoder().encodeToString(sectionDigest)); try { SignatureFileWriter.writeIndividualSection(out, sectionName, attrs); } catch (IOException e) { throw new RuntimeException("Failed to write in-memory .SF file", e); } } // A bug in the java.util.jar implementation of Android platforms up to version 1.6 will // cause a spurious IOException to be thrown if the length of the signature file is a // multiple of 1024 bytes. As a workaround, add an extra CRLF in this case. if ((out.size() > 0) && ((out.size() % 1024) == 0)) { try { SignatureFileWriter.writeSectionDelimiter(out); } catch (IOException e) { throw new RuntimeException("Failed to write to ByteArrayOutputStream", e); } } return out.toByteArray(); } @SuppressWarnings("restriction") private static byte[] generateSignatureBlock( SignerConfig signerConfig, byte[] signatureFileBytes) throws NoSuchAlgorithmException, InvalidKeyException, CertificateException, SignatureException { List<X509Certificate> signerCerts = signerConfig.certificates; X509Certificate signerCert = signerCerts.get(0); PublicKey signerPublicKey = signerCert.getPublicKey(); DigestAlgorithm digestAlgorithm = signerConfig.signatureDigestAlgorithm; Pair<String, AlgorithmId> signatureAlgs = getSignerInfoSignatureAlgorithm(signerPublicKey, digestAlgorithm); String jcaSignatureAlgorithm = signatureAlgs.getFirst(); byte[] signatureBytes; try { Signature signature = Signature.getInstance(jcaSignatureAlgorithm); signature.initSign(signerConfig.privateKey); signature.update(signatureFileBytes); signatureBytes = signature.sign(); } catch (InvalidKeyException e) { throw new InvalidKeyException("Failed to sign using " + jcaSignatureAlgorithm, e); } catch (SignatureException e) { throw new SignatureException("Failed to sign using " + jcaSignatureAlgorithm, e); } try { Signature signature = Signature.getInstance(jcaSignatureAlgorithm); signature.initVerify(signerPublicKey); signature.update(signatureFileBytes); if (!signature.verify(signatureBytes)) { throw new SignatureException("Signature did not verify"); } } catch (InvalidKeyException e) { throw new InvalidKeyException( "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + " public key from certificate", e); } catch (SignatureException e) { throw new SignatureException( "Failed to verify generated " + jcaSignatureAlgorithm + " signature using" + " public key from certificate", e); } X500Name issuerName; try { issuerName = new X500Name(signerCert.getIssuerX500Principal().getName()); } catch (IOException e) { throw new CertificateParsingException( "Failed to parse signer certificate issuer name", e); } AlgorithmId digestAlgorithmId = getSignerInfoDigestAlgorithm(digestAlgorithm); SignerInfo signerInfo = new SignerInfo( issuerName, signerCert.getSerialNumber(), digestAlgorithmId, signatureAlgs.getSecond(), signatureBytes); PKCS7 pkcs7 = new PKCS7( new AlgorithmId[] {digestAlgorithmId}, new ContentInfo(ContentInfo.DATA_OID, null), signerCerts.toArray(new X509Certificate[signerCerts.size()]), new SignerInfo[] {signerInfo}); ByteArrayOutputStream result = new ByteArrayOutputStream(); try { pkcs7.encodeSignedData(result); } catch (IOException e) { throw new SignatureException("Failed to encode PKCS#7 signed data", e); } return result.toByteArray(); } @SuppressWarnings("restriction") private static final AlgorithmId OID_DIGEST_SHA1 = getSupportedAlgorithmId("1.3.14.3.2.26"); @SuppressWarnings("restriction") private static final AlgorithmId OID_DIGEST_SHA256 = getSupportedAlgorithmId("2.16.840.1.101.3.4.2.1"); /** * Returns the {@code SignerInfo} {@code DigestAlgorithm} to use for {@code SignerInfo} signing * using the specified digest algorithm. */ @SuppressWarnings("restriction") private static AlgorithmId getSignerInfoDigestAlgorithm(DigestAlgorithm digestAlgorithm) { switch (digestAlgorithm) { case SHA1: return OID_DIGEST_SHA1; case SHA256: return OID_DIGEST_SHA256; default: throw new RuntimeException("Unsupported digest algorithm: " + digestAlgorithm); } } /** * Returns the JCA {@link Signature} algorithm and {@code SignerInfo} {@code SignatureAlgorithm} * to use for {@code SignerInfo} which signs with the specified key and digest algorithms. */ @SuppressWarnings("restriction") private static Pair<String, AlgorithmId> getSignerInfoSignatureAlgorithm( PublicKey publicKey, DigestAlgorithm digestAlgorithm) throws InvalidKeyException { // NOTE: This method on purpose uses hard-coded OIDs instead of // Algorithm.getId(JCA Signature Algorithm). This is to ensure that the generated SignedData // is compatible with all targeted Android platforms and is not dependent on changes in the // JCA Signature Algorithm -> OID mappings maintained by AlgorithmId.get(String). String keyAlgorithm = publicKey.getAlgorithm(); String digestPrefixForSigAlg; switch (digestAlgorithm) { case SHA1: digestPrefixForSigAlg = "SHA1"; break; case SHA256: digestPrefixForSigAlg = "SHA256"; break; default: throw new IllegalArgumentException( "Unexpected digest algorithm: " + digestAlgorithm); } if ("RSA".equalsIgnoreCase(keyAlgorithm)) { return Pair.of( digestPrefixForSigAlg + "withRSA", getSupportedAlgorithmId("1.2.840.113549.1.1.1") // RSA encryption ); } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { AlgorithmId sigAlgId; switch (digestAlgorithm) { case SHA1: sigAlgId = getSupportedAlgorithmId("1.2.840.10040.4.1"); // DSA break; case SHA256: // DSA signatures with SHA-256 in SignedData are accepted by Android API Level // 21 and higher. However, there are two ways to specify their SignedData // SignatureAlgorithm: dsaWithSha256 (2.16.840.1.101.3.4.3.2) and // dsa (1.2.840.10040.4.1). The latter works only on API Level 22+. Thus, we use // the former. sigAlgId = getSupportedAlgorithmId("2.16.840.1.101.3.4.3.2"); // DSA with SHA-256 break; default: throw new IllegalArgumentException( "Unexpected digest algorithm: " + digestAlgorithm); } return Pair.of(digestPrefixForSigAlg + "withDSA", sigAlgId); } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { return Pair.of( digestPrefixForSigAlg + "withECDSA", getSupportedAlgorithmId("1.2.840.10045.2.1") // EC public key ); } else { throw new InvalidKeyException("Unsupported key algorithm: " + keyAlgorithm); } } @SuppressWarnings("restriction") private static AlgorithmId getSupportedAlgorithmId(String oid) { try { return AlgorithmId.get(oid); } catch (NoSuchAlgorithmException e) { throw new RuntimeException("Unsupported OID: " + oid, e); } } private static String getEntryDigestAttributeName(DigestAlgorithm digestAlgorithm) { switch (digestAlgorithm) { case SHA1: return "SHA1-Digest"; case SHA256: return "SHA-256-Digest"; default: throw new IllegalArgumentException( "Unexpected content digest algorithm: " + digestAlgorithm); } } private static String getManifestDigestAttributeName(DigestAlgorithm digestAlgorithm) { switch (digestAlgorithm) { case SHA1: return "SHA1-Digest-Manifest"; case SHA256: return "SHA-256-Digest-Manifest"; default: throw new IllegalArgumentException( "Unexpected content digest algorithm: " + digestAlgorithm); } } }