diff --git a/app/signing/src/main/java/com/topjohnwu/signing/ApkSignerV2.java b/app/signing/src/main/java/com/topjohnwu/signing/ApkSignerV2.java new file mode 100644 index 000000000..5249d9883 --- /dev/null +++ b/app/signing/src/main/java/com/topjohnwu/signing/ApkSignerV2.java @@ -0,0 +1,772 @@ +package com.topjohnwu.signing; + +import java.nio.BufferUnderflowException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.security.DigestException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyFactory; +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.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.security.spec.AlgorithmParameterSpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * APK Signature Scheme v2 signer. + * + *

APK Signature Scheme v2 is a whole-file signature scheme which aims to protect every single + * bit of the APK, as opposed to the JAR Signature Scheme which protects only the names and + * uncompressed contents of ZIP entries. + */ +public abstract class ApkSignerV2 { + /* + * The two main goals of APK Signature Scheme v2 are: + * 1. Detect any unauthorized modifications to the APK. This is achieved by making the signature + * cover every byte of the APK being signed. + * 2. Enable much faster signature and integrity verification. This is achieved by requiring + * only a minimal amount of APK parsing before the signature is verified, thus completely + * bypassing ZIP entry decompression and by making integrity verification parallelizable by + * employing a hash tree. + * + * The generated signature block is wrapped into an APK Signing Block and inserted into the + * original APK immediately before the start of ZIP Central Directory. This is to ensure that + * JAR and ZIP parsers continue to work on the signed APK. The APK Signing Block is designed for + * extensibility. For example, a future signature scheme could insert its signatures there as + * well. The contract of the APK Signing Block is that all contents outside of the block must be + * protected by signatures inside the block. + */ + + public static final int SIGNATURE_RSA_PSS_WITH_SHA256 = 0x0101; + public static final int SIGNATURE_RSA_PSS_WITH_SHA512 = 0x0102; + public static final int SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256 = 0x0103; + public static final int SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512 = 0x0104; + public static final int SIGNATURE_ECDSA_WITH_SHA256 = 0x0201; + public static final int SIGNATURE_ECDSA_WITH_SHA512 = 0x0202; + public static final int SIGNATURE_DSA_WITH_SHA256 = 0x0301; + public static final int SIGNATURE_DSA_WITH_SHA512 = 0x0302; + + /** + * {@code .SF} file header section attribute indicating that the APK is signed not just with + * JAR signature scheme but also with APK Signature Scheme v2 or newer. This attribute + * facilitates v2 signature stripping detection. + * + *

The attribute contains a comma-separated set of signature scheme IDs. + */ + public static final String SF_ATTRIBUTE_ANDROID_APK_SIGNED_NAME = "X-Android-APK-Signed"; + public static final String SF_ATTRIBUTE_ANDROID_APK_SIGNED_VALUE = "2"; + + private static final int CONTENT_DIGEST_CHUNKED_SHA256 = 0; + private static final int CONTENT_DIGEST_CHUNKED_SHA512 = 1; + + private static final int CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES = 1024 * 1024; + + private static final byte[] APK_SIGNING_BLOCK_MAGIC = + new byte[] { + 0x41, 0x50, 0x4b, 0x20, 0x53, 0x69, 0x67, 0x20, + 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x20, 0x34, 0x32, + }; + private static final int APK_SIGNATURE_SCHEME_V2_BLOCK_ID = 0x7109871a; + + private ApkSignerV2() {} + + /** + * Signer configuration. + */ + public static final class SignerConfig { + /** Private key. */ + public PrivateKey privateKey; + + /** + * Certificates, with the first certificate containing the public key corresponding to + * {@link #privateKey}. + */ + public List certificates; + + /** + * List of signature algorithms with which to sign (see {@code SIGNATURE_...} constants). + */ + public List signatureAlgorithms; + } + + /** + * Signs the provided APK using APK Signature Scheme v2 and returns the signed APK as a list of + * consecutive chunks. + * + *

NOTE: To enable APK signature verifier to detect v2 signature stripping, header sections + * of META-INF/*.SF files of APK being signed must contain the + * {@code X-Android-APK-Signed: true} attribute. + * + * @param inputApk contents of the APK to be signed. The APK starts at the current position + * of the buffer and ends at the limit of the buffer. + * @param signerConfigs signer configurations, one for each signer. + * + * @throws ApkParseException if the APK cannot be parsed. + * @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 ByteBuffer[] sign( + ByteBuffer inputApk, + List signerConfigs) + throws ApkParseException, InvalidKeyException, SignatureException { + // Slice/create a view in the inputApk to make sure that: + // 1. inputApk is what's between position and limit of the original inputApk, and + // 2. changes to position, limit, and byte order are not reflected in the original. + ByteBuffer originalInputApk = inputApk; + inputApk = originalInputApk.slice(); + inputApk.order(ByteOrder.LITTLE_ENDIAN); + + // Locate ZIP End of Central Directory (EoCD), Central Directory, and check that Central + // Directory is immediately followed by the ZIP End of Central Directory. + int eocdOffset = ZipUtils.findZipEndOfCentralDirectoryRecord(inputApk); + if (eocdOffset == -1) { + throw new ApkParseException("Failed to locate ZIP End of Central Directory"); + } + if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(inputApk, eocdOffset)) { + throw new ApkParseException("ZIP64 format not supported"); + } + inputApk.position(eocdOffset); + long centralDirSizeLong = ZipUtils.getZipEocdCentralDirectorySizeBytes(inputApk); + if (centralDirSizeLong > Integer.MAX_VALUE) { + throw new ApkParseException( + "ZIP Central Directory size out of range: " + centralDirSizeLong); + } + int centralDirSize = (int) centralDirSizeLong; + long centralDirOffsetLong = ZipUtils.getZipEocdCentralDirectoryOffset(inputApk); + if (centralDirOffsetLong > Integer.MAX_VALUE) { + throw new ApkParseException( + "ZIP Central Directory offset in file out of range: " + centralDirOffsetLong); + } + int centralDirOffset = (int) centralDirOffsetLong; + int expectedEocdOffset = centralDirOffset + centralDirSize; + if (expectedEocdOffset < centralDirOffset) { + throw new ApkParseException( + "ZIP Central Directory extent too large. Offset: " + centralDirOffset + + ", size: " + centralDirSize); + } + if (eocdOffset != expectedEocdOffset) { + throw new ApkParseException( + "ZIP Central Directory not immeiately followed by ZIP End of" + + " Central Directory. CD end: " + expectedEocdOffset + + ", EoCD start: " + eocdOffset); + } + + // Create ByteBuffers holding the contents of everything before ZIP Central Directory, + // ZIP Central Directory, and ZIP End of Central Directory. + inputApk.clear(); + ByteBuffer beforeCentralDir = getByteBuffer(inputApk, centralDirOffset); + ByteBuffer centralDir = getByteBuffer(inputApk, eocdOffset - centralDirOffset); + // Create a copy of End of Central Directory because we'll need modify its contents later. + byte[] eocdBytes = new byte[inputApk.remaining()]; + inputApk.get(eocdBytes); + ByteBuffer eocd = ByteBuffer.wrap(eocdBytes); + eocd.order(inputApk.order()); + + // Figure which which digests to use for APK contents. + Set contentDigestAlgorithms = new HashSet<>(); + for (SignerConfig signerConfig : signerConfigs) { + for (int signatureAlgorithm : signerConfig.signatureAlgorithms) { + contentDigestAlgorithms.add( + getSignatureAlgorithmContentDigestAlgorithm(signatureAlgorithm)); + } + } + + // Compute digests of APK contents. + Map contentDigests; // digest algorithm ID -> digest + try { + contentDigests = + computeContentDigests( + contentDigestAlgorithms, + new ByteBuffer[] {beforeCentralDir, centralDir, eocd}); + } catch (DigestException e) { + throw new SignatureException("Failed to compute digests of APK", e); + } + + // Sign the digests and wrap the signatures and signer info into an APK Signing Block. + ByteBuffer apkSigningBlock = + ByteBuffer.wrap(generateApkSigningBlock(signerConfigs, contentDigests)); + + // Update Central Directory Offset in End of Central Directory Record. Central Directory + // follows the APK Signing Block and thus is shifted by the size of the APK Signing Block. + centralDirOffset += apkSigningBlock.remaining(); + eocd.clear(); + ZipUtils.setZipEocdCentralDirectoryOffset(eocd, centralDirOffset); + + // Follow the Java NIO pattern for ByteBuffer whose contents have been consumed. + originalInputApk.position(originalInputApk.limit()); + + // Reset positions (to 0) and limits (to capacity) in the ByteBuffers below to follow the + // Java NIO pattern for ByteBuffers which are ready for their contents to be read by caller. + // Contrary to the name, this does not clear the contents of these ByteBuffer. + beforeCentralDir.clear(); + centralDir.clear(); + eocd.clear(); + + // Insert APK Signing Block immediately before the ZIP Central Directory. + return new ByteBuffer[] { + beforeCentralDir, + apkSigningBlock, + centralDir, + eocd, + }; + } + + private static Map computeContentDigests( + Set digestAlgorithms, + ByteBuffer[] contents) throws DigestException { + // For each digest algorithm the result is computed as follows: + // 1. Each segment of contents is split into consecutive chunks of 1 MB in size. + // The final chunk will be shorter iff the length of segment is not a multiple of 1 MB. + // No chunks are produced for empty (zero length) segments. + // 2. The digest of each chunk is computed over the concatenation of byte 0xa5, the chunk's + // length in bytes (uint32 little-endian) and the chunk's contents. + // 3. The output digest is computed over the concatenation of the byte 0x5a, the number of + // chunks (uint32 little-endian) and the concatenation of digests of chunks of all + // segments in-order. + + int chunkCount = 0; + for (ByteBuffer input : contents) { + chunkCount += getChunkCount(input.remaining(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + } + + final Map digestsOfChunks = new HashMap<>(digestAlgorithms.size()); + for (int digestAlgorithm : digestAlgorithms) { + int digestOutputSizeBytes = getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm); + byte[] concatenationOfChunkCountAndChunkDigests = + new byte[5 + chunkCount * digestOutputSizeBytes]; + concatenationOfChunkCountAndChunkDigests[0] = 0x5a; + setUnsignedInt32LittleEngian( + chunkCount, concatenationOfChunkCountAndChunkDigests, 1); + digestsOfChunks.put(digestAlgorithm, concatenationOfChunkCountAndChunkDigests); + } + + int chunkIndex = 0; + byte[] chunkContentPrefix = new byte[5]; + chunkContentPrefix[0] = (byte) 0xa5; + // Optimization opportunity: digests of chunks can be computed in parallel. + for (ByteBuffer input : contents) { + while (input.hasRemaining()) { + int chunkSize = + Math.min(input.remaining(), CONTENT_DIGESTED_CHUNK_MAX_SIZE_BYTES); + final ByteBuffer chunk = getByteBuffer(input, chunkSize); + for (int digestAlgorithm : digestAlgorithms) { + String jcaAlgorithmName = + getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm); + MessageDigest md; + try { + md = MessageDigest.getInstance(jcaAlgorithmName); + } catch (NoSuchAlgorithmException e) { + throw new DigestException( + jcaAlgorithmName + " MessageDigest not supported", e); + } + // Reset position to 0 and limit to capacity. Position would've been modified + // by the preceding iteration of this loop. NOTE: Contrary to the method name, + // this does not modify the contents of the chunk. + chunk.clear(); + setUnsignedInt32LittleEngian(chunk.remaining(), chunkContentPrefix, 1); + md.update(chunkContentPrefix); + md.update(chunk); + byte[] concatenationOfChunkCountAndChunkDigests = + digestsOfChunks.get(digestAlgorithm); + int expectedDigestSizeBytes = + getContentDigestAlgorithmOutputSizeBytes(digestAlgorithm); + int actualDigestSizeBytes = + md.digest( + concatenationOfChunkCountAndChunkDigests, + 5 + chunkIndex * expectedDigestSizeBytes, + expectedDigestSizeBytes); + if (actualDigestSizeBytes != expectedDigestSizeBytes) { + throw new DigestException( + "Unexpected output size of " + md.getAlgorithm() + + " digest: " + actualDigestSizeBytes); + } + } + chunkIndex++; + } + } + + Map result = new HashMap<>(digestAlgorithms.size()); + for (Map.Entry entry : digestsOfChunks.entrySet()) { + int digestAlgorithm = entry.getKey(); + byte[] concatenationOfChunkCountAndChunkDigests = entry.getValue(); + String jcaAlgorithmName = getContentDigestAlgorithmJcaDigestAlgorithm(digestAlgorithm); + MessageDigest md; + try { + md = MessageDigest.getInstance(jcaAlgorithmName); + } catch (NoSuchAlgorithmException e) { + throw new DigestException(jcaAlgorithmName + " MessageDigest not supported", e); + } + result.put(digestAlgorithm, md.digest(concatenationOfChunkCountAndChunkDigests)); + } + return result; + } + + private static int getChunkCount(int inputSize, int chunkSize) { + return (inputSize + chunkSize - 1) / chunkSize; + } + + private static void setUnsignedInt32LittleEngian(int value, byte[] result, int offset) { + result[offset] = (byte) (value & 0xff); + result[offset + 1] = (byte) ((value >> 8) & 0xff); + result[offset + 2] = (byte) ((value >> 16) & 0xff); + result[offset + 3] = (byte) ((value >> 24) & 0xff); + } + + private static byte[] generateApkSigningBlock( + List signerConfigs, + Map contentDigests) throws InvalidKeyException, SignatureException { + byte[] apkSignatureSchemeV2Block = + generateApkSignatureSchemeV2Block(signerConfigs, contentDigests); + return generateApkSigningBlock(apkSignatureSchemeV2Block); + } + + private static byte[] generateApkSigningBlock(byte[] apkSignatureSchemeV2Block) { + // FORMAT: + // uint64: size (excluding this field) + // repeated ID-value pairs: + // uint64: size (excluding this field) + // uint32: ID + // (size - 4) bytes: value + // uint64: size (same as the one above) + // uint128: magic + + int resultSize = + 8 // size + + 8 + 4 + apkSignatureSchemeV2Block.length // v2Block as ID-value pair + + 8 // size + + 16 // magic + ; + ByteBuffer result = ByteBuffer.allocate(resultSize); + result.order(ByteOrder.LITTLE_ENDIAN); + long blockSizeFieldValue = resultSize - 8; + result.putLong(blockSizeFieldValue); + + long pairSizeFieldValue = 4 + apkSignatureSchemeV2Block.length; + result.putLong(pairSizeFieldValue); + result.putInt(APK_SIGNATURE_SCHEME_V2_BLOCK_ID); + result.put(apkSignatureSchemeV2Block); + + result.putLong(blockSizeFieldValue); + result.put(APK_SIGNING_BLOCK_MAGIC); + + return result.array(); + } + + private static byte[] generateApkSignatureSchemeV2Block( + List signerConfigs, + Map contentDigests) throws InvalidKeyException, SignatureException { + // FORMAT: + // * length-prefixed sequence of length-prefixed signer blocks. + + List signerBlocks = new ArrayList<>(signerConfigs.size()); + int signerNumber = 0; + for (SignerConfig signerConfig : signerConfigs) { + signerNumber++; + byte[] signerBlock; + try { + signerBlock = generateSignerBlock(signerConfig, contentDigests); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Signer #" + signerNumber + " failed", e); + } catch (SignatureException e) { + throw new SignatureException("Signer #" + signerNumber + " failed", e); + } + signerBlocks.add(signerBlock); + } + + return encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + encodeAsSequenceOfLengthPrefixedElements(signerBlocks), + }); + } + + private static byte[] generateSignerBlock( + SignerConfig signerConfig, + Map contentDigests) throws InvalidKeyException, SignatureException { + if (signerConfig.certificates.isEmpty()) { + throw new SignatureException("No certificates configured for signer"); + } + PublicKey publicKey = signerConfig.certificates.get(0).getPublicKey(); + + byte[] encodedPublicKey = encodePublicKey(publicKey); + + V2SignatureSchemeBlock.SignedData signedData = new V2SignatureSchemeBlock.SignedData(); + try { + signedData.certificates = encodeCertificates(signerConfig.certificates); + } catch (CertificateEncodingException e) { + throw new SignatureException("Failed to encode certificates", e); + } + + List> digests = + new ArrayList<>(signerConfig.signatureAlgorithms.size()); + for (int signatureAlgorithm : signerConfig.signatureAlgorithms) { + int contentDigestAlgorithm = + getSignatureAlgorithmContentDigestAlgorithm(signatureAlgorithm); + byte[] contentDigest = contentDigests.get(contentDigestAlgorithm); + if (contentDigest == null) { + throw new RuntimeException( + getContentDigestAlgorithmJcaDigestAlgorithm(contentDigestAlgorithm) + + " content digest for " + + getSignatureAlgorithmJcaSignatureAlgorithm(signatureAlgorithm) + + " not computed"); + } + digests.add(Pair.create(signatureAlgorithm, contentDigest)); + } + signedData.digests = digests; + + V2SignatureSchemeBlock.Signer signer = new V2SignatureSchemeBlock.Signer(); + // FORMAT: + // * length-prefixed sequence of length-prefixed digests: + // * uint32: signature algorithm ID + // * length-prefixed bytes: digest of contents + // * length-prefixed sequence of certificates: + // * length-prefixed bytes: X.509 certificate (ASN.1 DER encoded). + // * length-prefixed sequence of length-prefixed additional attributes: + // * uint32: ID + // * (length - 4) bytes: value + signer.signedData = encodeAsSequenceOfLengthPrefixedElements(new byte[][] { + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes(signedData.digests), + encodeAsSequenceOfLengthPrefixedElements(signedData.certificates), + // additional attributes + new byte[0], + }); + signer.publicKey = encodedPublicKey; + signer.signatures = new ArrayList<>(); + for (int signatureAlgorithm : signerConfig.signatureAlgorithms) { + Pair signatureParams = + getSignatureAlgorithmJcaSignatureAlgorithm(signatureAlgorithm); + String jcaSignatureAlgorithm = signatureParams.getFirst(); + AlgorithmParameterSpec jcaSignatureAlgorithmParams = signatureParams.getSecond(); + byte[] signatureBytes; + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initSign(signerConfig.privateKey); + if (jcaSignatureAlgorithmParams != null) { + signature.setParameter(jcaSignatureAlgorithmParams); + } + signature.update(signer.signedData); + signatureBytes = signature.sign(); + } catch (InvalidKeyException e) { + throw new InvalidKeyException("Failed sign using " + jcaSignatureAlgorithm, e); + } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException + | SignatureException e) { + throw new SignatureException("Failed sign using " + jcaSignatureAlgorithm, e); + } + + try { + Signature signature = Signature.getInstance(jcaSignatureAlgorithm); + signature.initVerify(publicKey); + if (jcaSignatureAlgorithmParams != null) { + signature.setParameter(jcaSignatureAlgorithmParams); + } + signature.update(signer.signedData); + 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 (NoSuchAlgorithmException | InvalidAlgorithmParameterException + | SignatureException e) { + throw new SignatureException("Failed to verify generated " + jcaSignatureAlgorithm + + " signature using public key from certificate", e); + } + + signer.signatures.add(Pair.create(signatureAlgorithm, signatureBytes)); + } + + // FORMAT: + // * length-prefixed signed data + // * length-prefixed sequence of length-prefixed signatures: + // * uint32: signature algorithm ID + // * length-prefixed bytes: signature of signed data + // * length-prefixed bytes: public key (X.509 SubjectPublicKeyInfo, ASN.1 DER encoded) + return encodeAsSequenceOfLengthPrefixedElements( + new byte[][] { + signer.signedData, + encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + signer.signatures), + signer.publicKey, + }); + } + + private static final class V2SignatureSchemeBlock { + private static final class Signer { + public byte[] signedData; + public List> signatures; + public byte[] publicKey; + } + + private static final class SignedData { + public List> digests; + public List certificates; + } + } + + private static byte[] encodePublicKey(PublicKey publicKey) throws InvalidKeyException { + byte[] encodedPublicKey = null; + if ("X.509".equals(publicKey.getFormat())) { + encodedPublicKey = publicKey.getEncoded(); + } + if (encodedPublicKey == null) { + try { + encodedPublicKey = + KeyFactory.getInstance(publicKey.getAlgorithm()) + .getKeySpec(publicKey, X509EncodedKeySpec.class) + .getEncoded(); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new InvalidKeyException( + "Failed to obtain X.509 encoded form of public key " + publicKey + + " of class " + publicKey.getClass().getName(), + e); + } + } + if ((encodedPublicKey == null) || (encodedPublicKey.length == 0)) { + throw new InvalidKeyException( + "Failed to obtain X.509 encoded form of public key " + publicKey + + " of class " + publicKey.getClass().getName()); + } + return encodedPublicKey; + } + + public static List encodeCertificates(List certificates) + throws CertificateEncodingException { + List result = new ArrayList<>(); + for (X509Certificate certificate : certificates) { + result.add(certificate.getEncoded()); + } + return result; + } + + private static byte[] encodeAsSequenceOfLengthPrefixedElements(List sequence) { + return encodeAsSequenceOfLengthPrefixedElements( + sequence.toArray(new byte[sequence.size()][])); + } + + private static byte[] encodeAsSequenceOfLengthPrefixedElements(byte[][] sequence) { + int payloadSize = 0; + for (byte[] element : sequence) { + payloadSize += 4 + element.length; + } + ByteBuffer result = ByteBuffer.allocate(payloadSize); + result.order(ByteOrder.LITTLE_ENDIAN); + for (byte[] element : sequence) { + result.putInt(element.length); + result.put(element); + } + return result.array(); + } + + private static byte[] encodeAsSequenceOfLengthPrefixedPairsOfIntAndLengthPrefixedBytes( + List> sequence) { + int resultSize = 0; + for (Pair element : sequence) { + resultSize += 12 + element.getSecond().length; + } + ByteBuffer result = ByteBuffer.allocate(resultSize); + result.order(ByteOrder.LITTLE_ENDIAN); + for (Pair element : sequence) { + byte[] second = element.getSecond(); + result.putInt(8 + second.length); + result.putInt(element.getFirst()); + result.putInt(second.length); + result.put(second); + } + return result.array(); + } + + /** + * Relative get method for reading {@code size} number of bytes from the current + * position of this buffer. + * + *

This method reads the next {@code size} bytes at this buffer's current position, + * returning them as a {@code ByteBuffer} with start set to 0, limit and capacity set to + * {@code size}, byte order set to this buffer's byte order; and then increments the position by + * {@code size}. + */ + private static ByteBuffer getByteBuffer(ByteBuffer source, int size) { + if (size < 0) { + throw new IllegalArgumentException("size: " + size); + } + int originalLimit = source.limit(); + int position = source.position(); + int limit = position + size; + if ((limit < position) || (limit > originalLimit)) { + throw new BufferUnderflowException(); + } + source.limit(limit); + try { + ByteBuffer result = source.slice(); + result.order(source.order()); + source.position(limit); + return result; + } finally { + source.limit(originalLimit); + } + } + + private static Pair + getSignatureAlgorithmJcaSignatureAlgorithm(int sigAlgorithm) { + switch (sigAlgorithm) { + case SIGNATURE_RSA_PSS_WITH_SHA256: + return Pair.create( + "SHA256withRSA/PSS", + new PSSParameterSpec( + "SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 256 / 8, 1)); + case SIGNATURE_RSA_PSS_WITH_SHA512: + return Pair.create( + "SHA512withRSA/PSS", + new PSSParameterSpec( + "SHA-512", "MGF1", MGF1ParameterSpec.SHA512, 512 / 8, 1)); + case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256: + return Pair.create("SHA256withRSA", null); + case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512: + return Pair.create("SHA512withRSA", null); + case SIGNATURE_ECDSA_WITH_SHA256: + return Pair.create("SHA256withECDSA", null); + case SIGNATURE_ECDSA_WITH_SHA512: + return Pair.create("SHA512withECDSA", null); + case SIGNATURE_DSA_WITH_SHA256: + return Pair.create("SHA256withDSA", null); + case SIGNATURE_DSA_WITH_SHA512: + return Pair.create("SHA512withDSA", null); + default: + throw new IllegalArgumentException( + "Unknown signature algorithm: 0x" + + Long.toHexString(sigAlgorithm & 0xffffffff)); + } + } + + private static int getSignatureAlgorithmContentDigestAlgorithm(int sigAlgorithm) { + switch (sigAlgorithm) { + case SIGNATURE_RSA_PSS_WITH_SHA256: + case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256: + case SIGNATURE_ECDSA_WITH_SHA256: + case SIGNATURE_DSA_WITH_SHA256: + return CONTENT_DIGEST_CHUNKED_SHA256; + case SIGNATURE_RSA_PSS_WITH_SHA512: + case SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512: + case SIGNATURE_ECDSA_WITH_SHA512: + case SIGNATURE_DSA_WITH_SHA512: + return CONTENT_DIGEST_CHUNKED_SHA512; + default: + throw new IllegalArgumentException( + "Unknown signature algorithm: 0x" + + Long.toHexString(sigAlgorithm & 0xffffffff)); + } + } + + private static String getContentDigestAlgorithmJcaDigestAlgorithm(int digestAlgorithm) { + switch (digestAlgorithm) { + case CONTENT_DIGEST_CHUNKED_SHA256: + return "SHA-256"; + case CONTENT_DIGEST_CHUNKED_SHA512: + return "SHA-512"; + default: + throw new IllegalArgumentException( + "Unknown content digest algorthm: " + digestAlgorithm); + } + } + + private static int getContentDigestAlgorithmOutputSizeBytes(int digestAlgorithm) { + switch (digestAlgorithm) { + case CONTENT_DIGEST_CHUNKED_SHA256: + return 256 / 8; + case CONTENT_DIGEST_CHUNKED_SHA512: + return 512 / 8; + default: + throw new IllegalArgumentException( + "Unknown content digest algorthm: " + digestAlgorithm); + } + } + + /** + * Indicates that APK file could not be parsed. + */ + public static class ApkParseException extends Exception { + private static final long serialVersionUID = 1L; + + public ApkParseException(String message) { + super(message); + } + + public ApkParseException(String message, Throwable cause) { + super(message, cause); + } + } + + /** + * Pair of two elements. + */ + private static class Pair { + private final A mFirst; + private final B mSecond; + + private Pair(A first, B second) { + mFirst = first; + mSecond = second; + } + + public static Pair create(A first, B second) { + return new Pair<>(first, second); + } + + public A getFirst() { + return mFirst; + } + + public B getSecond() { + return mSecond; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = 1; + result = prime * result + ((mFirst == null) ? 0 : mFirst.hashCode()); + result = prime * result + ((mSecond == null) ? 0 : mSecond.hashCode()); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + @SuppressWarnings("rawtypes") + Pair other = (Pair) obj; + if (mFirst == null) { + if (other.mFirst != null) { + return false; + } + } else if (!mFirst.equals(other.mFirst)) { + return false; + } + if (mSecond == null) { + return other.mSecond == null; + } else return mSecond.equals(other.mSecond); + } + } +} diff --git a/app/signing/src/main/java/com/topjohnwu/signing/SignAPK.java b/app/signing/src/main/java/com/topjohnwu/signing/SignApk.java similarity index 51% rename from app/signing/src/main/java/com/topjohnwu/signing/SignAPK.java rename to app/signing/src/main/java/com/topjohnwu/signing/SignApk.java index 4c7b070e0..64a204f08 100644 --- a/app/signing/src/main/java/com/topjohnwu/signing/SignAPK.java +++ b/app/signing/src/main/java/com/topjohnwu/signing/SignApk.java @@ -2,9 +2,7 @@ package com.topjohnwu.signing; import org.bouncycastle.asn1.ASN1Encoding; import org.bouncycastle.asn1.ASN1InputStream; -import org.bouncycastle.asn1.ASN1ObjectIdentifier; import org.bouncycastle.asn1.ASN1OutputStream; -import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; import org.bouncycastle.cert.jcajce.JcaCertStore; import org.bouncycastle.cms.CMSException; import org.bouncycastle.cms.CMSProcessableByteArray; @@ -12,34 +10,38 @@ import org.bouncycastle.cms.CMSSignedData; import org.bouncycastle.cms.CMSSignedDataGenerator; import org.bouncycastle.cms.CMSTypedData; import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.OperatorCreationException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder; import org.bouncycastle.util.encoders.Base64; -import java.io.BufferedOutputStream; import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.FilterOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintStream; -import java.io.RandomAccessFile; +import java.nio.ByteBuffer; import java.security.DigestOutputStream; import java.security.GeneralSecurityException; +import java.security.InvalidKeyException; import java.security.MessageDigest; import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Security; import java.security.cert.CertificateEncodingException; import java.security.cert.X509Certificate; import java.util.ArrayList; import java.util.Collections; import java.util.Enumeration; +import java.util.Iterator; +import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.TimeZone; import java.util.TreeMap; import java.util.jar.Attributes; import java.util.jar.JarEntry; @@ -49,72 +51,28 @@ import java.util.jar.Manifest; import java.util.regex.Pattern; /* -* Modified from from AOSP -* https://android.googlesource.com/platform/build/+/refs/heads/marshmallow-release/tools/signapk/SignApk.java -* */ - -public class SignAPK { + * Modified from from AOSP + * https://android.googlesource.com/platform/build/+/refs/tags/android-7.1.2_r39/tools/signapk/src/com/android/signapk/SignApk.java + * */ +public class SignApk { private static final String CERT_SF_NAME = "META-INF/CERT.SF"; private static final String CERT_SIG_NAME = "META-INF/CERT.%s"; + private static final String CERT_SF_MULTI_NAME = "META-INF/CERT%d.SF"; + private static final String CERT_SIG_MULTI_NAME = "META-INF/CERT%d.%s"; // bitmasks for which hash algorithms we need the manifest to include. private static final int USE_SHA1 = 1; private static final int USE_SHA256 = 2; - public static void signAndAdjust(X509Certificate cert, PrivateKey key, - JarMap input, OutputStream output) throws Exception { - File temp1 = File.createTempFile("signAPK", null); - File temp2 = File.createTempFile("signAPK", null); - - try { - try (OutputStream out = new BufferedOutputStream(new FileOutputStream(temp1))) { - sign(cert, key, input, out, false); - } - - ZipAdjust.adjust(temp1, temp2); - - try (JarMap map = JarMap.open(temp2, false)) { - sign(cert, key, map, output, true); - } - } finally { - temp1.delete(); - temp2.delete(); - } - } - - public static void sign(X509Certificate cert, PrivateKey key, - JarMap input, OutputStream output) throws Exception { - sign(cert, key, input, output, false); - } - - private static void sign(X509Certificate cert, PrivateKey key, JarMap input, - OutputStream output, boolean wholeFile) throws Exception { - int hashes = 0; - hashes |= getDigestAlgorithm(cert); - - // Set the ZIP file timestamp to the starting valid time - // of the 0th certificate plus one hour (to match what - // we've historically done). - long timestamp = cert.getNotBefore().getTime() + 3600L * 1000; - - if (wholeFile) { - signWholeFile(input.getFile(), cert, key, output); - } else { - JarOutputStream outputJar = new JarOutputStream(output); - // For signing .apks, use the maximum compression to make - // them as small as possible (since they live forever on - // the system partition). For OTA packages, use the - // default compression level, which is much much faster - // and produces output that is only a tiny bit larger - // (~0.1% on full OTA packages I tested). - outputJar.setLevel(9); - Manifest manifest = addDigestsToManifest(input, hashes); - copyFiles(manifest, input, outputJar, timestamp, 4); - signFile(manifest, input, cert, key, outputJar); - outputJar.close(); - } - } + /** + * Digest algorithm used when signing the APK using APK Signature Scheme v2. + */ + private static final String APK_SIG_SCHEME_V2_DIGEST_ALGORITHM = "SHA-256"; + // Files matching this pattern are not copied to the output. + private static final Pattern stripPattern = + Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" + + Pattern.quote(JarFile.MANIFEST_NAME) + ")$"); /** * Return one of USE_SHA1 or USE_SHA256 according to the signature @@ -122,7 +80,7 @@ public class SignAPK { */ private static int getDigestAlgorithm(X509Certificate cert) { String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US); - if (sigAlg.startsWith("SHA1WITHRSA") || sigAlg.startsWith("MD5WITHRSA")) { + if ("SHA1WITHRSA".equals(sigAlg) || "MD5WITHRSA".equals(sigAlg)) { return USE_SHA1; } else if (sigAlg.startsWith("SHA256WITH")) { return USE_SHA256; @@ -132,9 +90,10 @@ public class SignAPK { } } - /** Returns the expected signature algorithm for this key type. */ + /** + * Returns the expected signature algorithm for this key type. + */ private static String getSignatureAlgorithm(X509Certificate cert) { - String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US); String keyType = cert.getPublicKey().getAlgorithm().toUpperCase(Locale.US); if ("RSA".equalsIgnoreCase(keyType)) { if (getDigestAlgorithm(cert) == USE_SHA256) { @@ -149,11 +108,6 @@ public class SignAPK { } } - // Files matching this pattern are not copied to the output. - private static Pattern stripPattern = - Pattern.compile("^(META-INF/((.*)[.](SF|RSA|DSA|EC)|com/android/otacert))|(" + - Pattern.quote(JarFile.MANIFEST_NAME) + ")$"); - /** * Add the hash(es) of every file to the manifest, creating it if * necessary. @@ -169,6 +123,7 @@ public class SignAPK { main.putValue("Manifest-Version", "1.0"); main.putValue("Created-By", "1.0 (Android SignApk)"); } + MessageDigest md_sha1 = null; MessageDigest md_sha256 = null; if ((hashes & USE_SHA1) != 0) { @@ -177,30 +132,51 @@ public class SignAPK { if ((hashes & USE_SHA256) != 0) { md_sha256 = MessageDigest.getInstance("SHA256"); } + byte[] buffer = new byte[4096]; int num; + // We sort the input entries by name, and add them to the // output manifest in sorted order. We expect that the output // map will be deterministic. + TreeMap byName = new TreeMap<>(); + for (Enumeration e = jar.entries(); e.hasMoreElements(); ) { JarEntry entry = e.nextElement(); byName.put(entry.getName(), entry); } - for (JarEntry entry: byName.values()) { + + for (JarEntry entry : byName.values()) { String name = entry.getName(); - if (!entry.isDirectory() && - (stripPattern == null || !stripPattern.matcher(name).matches())) { + if (!entry.isDirectory() && !stripPattern.matcher(name).matches()) { InputStream data = jar.getInputStream(entry); while ((num = data.read(buffer)) > 0) { if (md_sha1 != null) md_sha1.update(buffer, 0, num); if (md_sha256 != null) md_sha256.update(buffer, 0, num); } - Attributes attr = new Attributes(); + + Attributes attr = null; + if (input != null) attr = input.getAttributes(name); + attr = attr != null ? new Attributes(attr) : new Attributes(); + // Remove any previously computed digests from this entry's attributes. + for (Iterator i = attr.keySet().iterator(); i.hasNext(); ) { + Object key = i.next(); + if (!(key instanceof Attributes.Name)) { + continue; + } + String attributeNameLowerCase = + key.toString().toLowerCase(Locale.US); + if (attributeNameLowerCase.endsWith("-digest")) { + i.remove(); + } + } + // Add SHA-1 digest if requested if (md_sha1 != null) { attr.putValue("SHA1-Digest", new String(Base64.encode(md_sha1.digest()), "ASCII")); } + // Add SHA-256 digest if requested if (md_sha256 != null) { attr.putValue("SHA-256-Digest", new String(Base64.encode(md_sha256.digest()), "ASCII")); @@ -208,62 +184,39 @@ public class SignAPK { output.getEntries().put(name, attr); } } + return output; } /** - * Write to another stream and track how many bytes have been - * written. + * Write a .SF file with a digest of the specified manifest. */ - private static class CountOutputStream extends FilterOutputStream { - private int mCount; - public CountOutputStream(OutputStream out) { - super(out); - mCount = 0; - } - @Override - public void write(int b) throws IOException { - super.write(b); - mCount++; - } - @Override - public void write(byte[] b, int off, int len) throws IOException { - super.write(b, off, len); - mCount += len; - } - public int size() { - return mCount; - } - } - - /** - * An OutputStream that does literally nothing - */ - private static OutputStream stubStream = new OutputStream() { - @Override - public void write(int b) {} - - @Override - public void write(byte[] b, int off, int len) {} - }; - - /** Write a .SF file with a digest of the specified manifest. */ - private static void writeSignatureFile(Manifest manifest, OutputStream out, int hash) + private static void writeSignatureFile(Manifest manifest, OutputStream out, + int hash) throws IOException, GeneralSecurityException { Manifest sf = new Manifest(); Attributes main = sf.getMainAttributes(); main.putValue("Signature-Version", "1.0"); main.putValue("Created-By", "1.0 (Android SignApk)"); - MessageDigest md = MessageDigest.getInstance( - hash == USE_SHA256 ? "SHA256" : "SHA1"); - PrintStream print = new PrintStream( - new DigestOutputStream(stubStream, md), + // Add APK Signature Scheme v2 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. + main.putValue( + ApkSignerV2.SF_ATTRIBUTE_ANDROID_APK_SIGNED_NAME, + ApkSignerV2.SF_ATTRIBUTE_ANDROID_APK_SIGNED_VALUE); + + MessageDigest md = MessageDigest.getInstance(hash == USE_SHA256 ? "SHA256" : "SHA1"); + PrintStream print = new PrintStream(new DigestOutputStream(new ByteArrayOutputStream(), md), true, "UTF-8"); + // Digest of the entire manifest manifest.write(print); print.flush(); main.putValue(hash == USE_SHA256 ? "SHA-256-Digest-Manifest" : "SHA1-Digest-Manifest", new String(Base64.encode(md.digest()), "ASCII")); + Map entries = manifest.getEntries(); for (Map.Entry entry : entries.entrySet()) { // Digest of the manifest stanza for this entry. @@ -273,13 +226,16 @@ public class SignAPK { } print.print("\r\n"); print.flush(); + Attributes sfAttr = new Attributes(); sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest", new String(Base64.encode(md.digest()), "ASCII")); sf.getEntries().put(entry.getKey(), sfAttr); } + CountOutputStream cout = new CountOutputStream(out); sf.write(cout); + // 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. @@ -290,10 +246,11 @@ public class SignAPK { } } - /** Sign data and write the digital signature to 'out'. */ + /** + * Sign data and write the digital signature to 'out'. + */ private static void writeSignatureBlock( - CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, - OutputStream out) + CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, OutputStream out) throws IOException, CertificateEncodingException, OperatorCreationException, @@ -301,19 +258,22 @@ public class SignAPK { ArrayList certList = new ArrayList<>(1); certList.add(publicKey); JcaCertStore certs = new JcaCertStore(certList); + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey)) .build(privateKey); gen.addSignerInfoGenerator( - new JcaSignerInfoGeneratorBuilder( - new JcaDigestCalculatorProviderBuilder().build()) + new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().build()) .setDirectSignature(true) - .build(signer, publicKey)); + .build(signer, publicKey) + ); gen.addCertificates(certs); CMSSignedData sigData = gen.generate(data, false); - ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded()); - ASN1OutputStream dos = ASN1OutputStream.create(out, ASN1Encoding.DER); - dos.writeObject(asn1.readObject()); + + try (ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded())) { + ASN1OutputStream dos = ASN1OutputStream.create(out, ASN1Encoding.DER); + dos.writeObject(asn1.readObject()); + } } /** @@ -323,27 +283,37 @@ public class SignAPK { * more efficient. */ private static void copyFiles(Manifest manifest, JarMap in, JarOutputStream out, - long timestamp, int alignment) throws IOException { + long timestamp, int defaultAlignment) throws IOException { byte[] buffer = new byte[4096]; int num; + Map entries = manifest.getEntries(); ArrayList names = new ArrayList<>(entries.keySet()); Collections.sort(names); + boolean firstEntry = true; long offset = 0L; + // We do the copy in two passes -- first copying all the // entries that are STORED, then copying all the entries that // have any other compression flag (which in practice means // DEFLATED). This groups all the stored entries together at // the start of the file and makes it easier to do alignment // on them (since only stored entries are aligned). + for (String name : names) { JarEntry inEntry = in.getJarEntry(name); - JarEntry outEntry = null; + JarEntry outEntry; if (inEntry.getMethod() != JarEntry.STORED) continue; // Preserve the STORED method of the input entry. outEntry = new JarEntry(inEntry); outEntry.setTime(timestamp); + // Discard comment and extra fields of this entry to + // simplify alignment logic below and for consistency with + // how compressed entries are handled later. + outEntry.setComment(null); + outEntry.setExtra(null); + // 'offset' is the offset into the file at which we expect // the file data to begin. This is the value we need to // make a multiple of 'alignement'. @@ -357,15 +327,18 @@ public class SignAPK { offset += 4; firstEntry = false; } + int alignment = getStoredEntryDataAlignment(name, defaultAlignment); if (alignment > 0 && (offset % alignment != 0)) { // Set the "extra data" of the entry to between 1 and // alignment-1 bytes, to make the file data begin at // an aligned offset. - int needed = alignment - (int)(offset % alignment); + int needed = alignment - (int) (offset % alignment); outEntry.setExtra(new byte[needed]); offset += needed; } + out.putNextEntry(outEntry); + InputStream data = in.getInputStream(inEntry); while ((num = data.read(buffer)) > 0) { out.write(buffer, 0, num); @@ -373,17 +346,20 @@ public class SignAPK { } out.flush(); } + // Copy all the non-STORED entries. We don't attempt to // maintain the 'offset' variable past this point; we don't do // alignment on these entries. + for (String name : names) { JarEntry inEntry = in.getJarEntry(name); - JarEntry outEntry = null; + JarEntry outEntry; if (inEntry.getMethod() == JarEntry.STORED) continue; // Create a new entry so that the compressed len is recomputed. outEntry = new JarEntry(name); outEntry.setTime(timestamp); out.putNextEntry(outEntry); + InputStream data = in.getInputStream(inEntry); while ((num = data.read(buffer)) > 0) { out.write(buffer, 0, num); @@ -392,135 +368,203 @@ public class SignAPK { } } - // This class is to provide a file's content, but trimming out the last two bytes - // Used for signWholeFile - private static class CMSProcessableFile implements CMSTypedData { - - private ASN1ObjectIdentifier type; - private RandomAccessFile file; - - CMSProcessableFile(File file) throws FileNotFoundException { - this.file = new RandomAccessFile(file, "r"); - type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()); + /** + * Returns the multiple (in bytes) at which the provided {@code STORED} entry's data must start + * relative to start of file or {@code 0} if alignment of this entry's data is not important. + */ + private static int getStoredEntryDataAlignment(String entryName, int defaultAlignment) { + if (defaultAlignment <= 0) { + return 0; } - @Override - public ASN1ObjectIdentifier getContentType() { - return type; - } - - @Override - public void write(OutputStream out) throws IOException, CMSException { - file.seek(0); - int read; - byte buffer[] = new byte[4096]; - int len = (int) file.length() - 2; - while ((read = file.read(buffer, 0, len < buffer.length ? len : buffer.length)) > 0) { - out.write(buffer, 0, read); - len -= read; - } - } - - @Override - public Object getContent() { - return file; - } - - byte[] getTail() throws IOException { - byte tail[] = new byte[22]; - file.seek(file.length() - 22); - file.readFully(tail); - return tail; + if (entryName.endsWith(".so")) { + // Align .so contents to memory page boundary to enable memory-mapped + // execution. + return 4096; + } else { + return defaultAlignment; } } - private static void signWholeFile(File input, X509Certificate publicKey, - PrivateKey privateKey, OutputStream outputStream) - throws Exception { - ByteArrayOutputStream temp = new ByteArrayOutputStream(); - // put a readable message and a null char at the start of the - // archive comment, so that tools that display the comment - // (hopefully) show something sensible. - // TODO: anything more useful we can put in this message? - byte[] message = "signed by SignApk".getBytes("UTF-8"); - temp.write(message); - temp.write(0); - - CMSProcessableFile cmsFile = new CMSProcessableFile(input); - writeSignatureBlock(cmsFile, publicKey, privateKey, temp); - - // For a zip with no archive comment, the - // end-of-central-directory record will be 22 bytes long, so - // we expect to find the EOCD marker 22 bytes from the end. - byte[] zipData = cmsFile.getTail(); - if (zipData[zipData.length-22] != 0x50 || - zipData[zipData.length-21] != 0x4b || - zipData[zipData.length-20] != 0x05 || - zipData[zipData.length-19] != 0x06) { - throw new IllegalArgumentException("zip data already has an archive comment"); - } - int total_size = temp.size() + 6; - if (total_size > 0xffff) { - throw new IllegalArgumentException("signature is too big for ZIP file comment"); - } - // signature starts this many bytes from the end of the file - int signature_start = total_size - message.length - 1; - temp.write(signature_start & 0xff); - temp.write((signature_start >> 8) & 0xff); - // Why the 0xff bytes? In a zip file with no archive comment, - // bytes [-6:-2] of the file are the little-endian offset from - // the start of the file to the central directory. So for the - // two high bytes to be 0xff 0xff, the archive would have to - // be nearly 4GB in size. So it's unlikely that a real - // commentless archive would have 0xffs here, and lets us tell - // an old signed archive from a new one. - temp.write(0xff); - temp.write(0xff); - temp.write(total_size & 0xff); - temp.write((total_size >> 8) & 0xff); - temp.flush(); - // Signature verification checks that the EOCD header is the - // last such sequence in the file (to avoid minzip finding a - // fake EOCD appended after the signature in its scan). The - // odds of producing this sequence by chance are very low, but - // let's catch it here if it does. - byte[] b = temp.toByteArray(); - for (int i = 0; i < b.length-3; ++i) { - if (b[i] == 0x50 && b[i+1] == 0x4b && b[i+2] == 0x05 && b[i+3] == 0x06) { - throw new IllegalArgumentException("found spurious EOCD header at " + i); - } - } - cmsFile.write(outputStream); - outputStream.write(total_size & 0xff); - outputStream.write((total_size >> 8) & 0xff); - temp.writeTo(outputStream); - outputStream.close(); - } - - private static void signFile(Manifest manifest, JarMap inputJar, - X509Certificate cert, PrivateKey privateKey, - JarOutputStream outputJar) - throws Exception { - // Assume the certificate is valid for at least an hour. - long timestamp = cert.getNotBefore().getTime() + 3600L * 1000; + private static void signFile(Manifest manifest, + X509Certificate[] publicKey, PrivateKey[] privateKey, + long timestamp, JarOutputStream outputJar) throws Exception { // MANIFEST.MF JarEntry je = new JarEntry(JarFile.MANIFEST_NAME); je.setTime(timestamp); outputJar.putNextEntry(je); manifest.write(outputJar); - je = new JarEntry(CERT_SF_NAME); - je.setTime(timestamp); - outputJar.putNextEntry(je); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - writeSignatureFile(manifest, baos, getDigestAlgorithm(cert)); - byte[] signedData = baos.toByteArray(); - outputJar.write(signedData); - // CERT.{EC,RSA} / CERT#.{EC,RSA} - final String keyType = cert.getPublicKey().getAlgorithm(); - je = new JarEntry(String.format(CERT_SIG_NAME, keyType)); - je.setTime(timestamp); - outputJar.putNextEntry(je); - writeSignatureBlock(new CMSProcessableByteArray(signedData), - cert, privateKey, outputJar); + + int numKeys = publicKey.length; + for (int k = 0; k < numKeys; ++k) { + // CERT.SF / CERT#.SF + je = new JarEntry(numKeys == 1 ? CERT_SF_NAME : + (String.format(Locale.US, CERT_SF_MULTI_NAME, k))); + je.setTime(timestamp); + outputJar.putNextEntry(je); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + writeSignatureFile(manifest, baos, getDigestAlgorithm(publicKey[k])); + byte[] signedData = baos.toByteArray(); + outputJar.write(signedData); + + // CERT.{EC,RSA} / CERT#.{EC,RSA} + final String keyType = publicKey[k].getPublicKey().getAlgorithm(); + je = new JarEntry(numKeys == 1 ? (String.format(CERT_SIG_NAME, keyType)) : + (String.format(Locale.US, CERT_SIG_MULTI_NAME, k, keyType))); + je.setTime(timestamp); + outputJar.putNextEntry(je); + writeSignatureBlock(new CMSProcessableByteArray(signedData), + publicKey[k], privateKey[k], outputJar); + } + } + + /** + * Converts the provided lists of private keys, their X.509 certificates, and digest algorithms + * into a list of APK Signature Scheme v2 {@code SignerConfig} instances. + */ + private static List createV2SignerConfigs( + PrivateKey[] privateKeys, X509Certificate[] certificates, String[] digestAlgorithms) + throws InvalidKeyException { + if (privateKeys.length != certificates.length) { + throw new IllegalArgumentException( + "The number of private keys must match the number of certificates: " + + privateKeys.length + " vs" + certificates.length); + } + List result = new ArrayList<>(privateKeys.length); + for (int i = 0; i < privateKeys.length; i++) { + PrivateKey privateKey = privateKeys[i]; + X509Certificate certificate = certificates[i]; + PublicKey publicKey = certificate.getPublicKey(); + String keyAlgorithm = privateKey.getAlgorithm(); + if (!keyAlgorithm.equalsIgnoreCase(publicKey.getAlgorithm())) { + throw new InvalidKeyException( + "Key algorithm of private key #" + (i + 1) + " does not match key" + + " algorithm of public key #" + (i + 1) + ": " + keyAlgorithm + + " vs " + publicKey.getAlgorithm()); + } + ApkSignerV2.SignerConfig signerConfig = new ApkSignerV2.SignerConfig(); + signerConfig.privateKey = privateKey; + signerConfig.certificates = Collections.singletonList(certificate); + List signatureAlgorithms = new ArrayList<>(digestAlgorithms.length); + for (String digestAlgorithm : digestAlgorithms) { + try { + signatureAlgorithms.add(getV2SignatureAlgorithm(keyAlgorithm, digestAlgorithm)); + } catch (IllegalArgumentException e) { + throw new InvalidKeyException( + "Unsupported key and digest algorithm combination for signer #" + + (i + 1), e); + } + } + signerConfig.signatureAlgorithms = signatureAlgorithms; + result.add(signerConfig); + } + return result; + } + + private static int getV2SignatureAlgorithm(String keyAlgorithm, String digestAlgorithm) { + if ("SHA-256".equalsIgnoreCase(digestAlgorithm)) { + if ("RSA".equalsIgnoreCase(keyAlgorithm)) { + // Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee + // deterministic signatures which make life easier for OTA updates (fewer files + // changed when deterministic signature schemes are used). + return ApkSignerV2.SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA256; + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + return ApkSignerV2.SIGNATURE_ECDSA_WITH_SHA256; + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + return ApkSignerV2.SIGNATURE_DSA_WITH_SHA256; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + } else if ("SHA-512".equalsIgnoreCase(digestAlgorithm)) { + if ("RSA".equalsIgnoreCase(keyAlgorithm)) { + // Use RSASSA-PKCS1-v1_5 signature scheme instead of RSASSA-PSS to guarantee + // deterministic signatures which make life easier for OTA updates (fewer files + // changed when deterministic signature schemes are used). + return ApkSignerV2.SIGNATURE_RSA_PKCS1_V1_5_WITH_SHA512; + } else if ("EC".equalsIgnoreCase(keyAlgorithm)) { + return ApkSignerV2.SIGNATURE_ECDSA_WITH_SHA512; + } else if ("DSA".equalsIgnoreCase(keyAlgorithm)) { + return ApkSignerV2.SIGNATURE_DSA_WITH_SHA512; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + } else { + throw new IllegalArgumentException("Unsupported digest algorithm: " + digestAlgorithm); + } + } + + public static void sign(X509Certificate cert, PrivateKey key, + JarMap inputJar, FileOutputStream outputFile) throws Exception { + int alignment = 4; + int hashes = 0; + + X509Certificate[] publicKey = new X509Certificate[1]; + publicKey[0] = cert; + hashes |= getDigestAlgorithm(publicKey[0]); + + // Set all ZIP file timestamps to Jan 1 2009 00:00:00. + long timestamp = 1230768000000L; + // The Java ZipEntry API we're using converts milliseconds since epoch into MS-DOS + // timestamp using the current timezone. We thus adjust the milliseconds since epoch + // value to end up with MS-DOS timestamp of Jan 1 2009 00:00:00. + timestamp -= TimeZone.getDefault().getOffset(timestamp); + + PrivateKey[] privateKey = new PrivateKey[1]; + privateKey[0] = key; + + // Generate, in memory, an APK signed using standard JAR Signature Scheme. + ByteArrayOutputStream v1SignedApkBuf = new ByteArrayOutputStream(); + JarOutputStream outputJar = new JarOutputStream(v1SignedApkBuf); + // Use maximum compression for compressed entries because the APK lives forever on + // the system partition. + outputJar.setLevel(9); + Manifest manifest = addDigestsToManifest(inputJar, hashes); + copyFiles(manifest, inputJar, outputJar, timestamp, alignment); + signFile(manifest, publicKey, privateKey, timestamp, outputJar); + outputJar.close(); + ByteBuffer v1SignedApk = ByteBuffer.wrap(v1SignedApkBuf.toByteArray()); + v1SignedApkBuf.reset(); + + ByteBuffer[] outputChunks; + List signerConfigs = createV2SignerConfigs(privateKey, publicKey, + new String[]{APK_SIG_SCHEME_V2_DIGEST_ALGORITHM}); + outputChunks = ApkSignerV2.sign(v1SignedApk, signerConfigs); + + // This assumes outputChunks are array-backed. To avoid this assumption, the + // code could be rewritten to use FileChannel. + for (ByteBuffer outputChunk : outputChunks) { + outputFile.write(outputChunk.array(), + outputChunk.arrayOffset() + outputChunk.position(), outputChunk.remaining()); + outputChunk.position(outputChunk.limit()); + } + } + + /** + * Write to another stream and track how many bytes have been + * written. + */ + private static class CountOutputStream extends FilterOutputStream { + private int mCount; + + public CountOutputStream(OutputStream out) { + super(out); + mCount = 0; + } + + @Override + public void write(int b) throws IOException { + super.write(b); + mCount++; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + super.write(b, off, len); + mCount += len; + } + + public int size() { + return mCount; + } } } diff --git a/app/signing/src/main/java/com/topjohnwu/signing/ZipAdjust.java b/app/signing/src/main/java/com/topjohnwu/signing/ZipAdjust.java deleted file mode 100644 index d673e09bb..000000000 --- a/app/signing/src/main/java/com/topjohnwu/signing/ZipAdjust.java +++ /dev/null @@ -1,277 +0,0 @@ -package com.topjohnwu.signing; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.RandomAccessFile; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; - -public class ZipAdjust { - - public static void adjust(File input, File output) throws IOException { - try ( - RandomAccessFile in = new RandomAccessFile(input, "r"); - FileOutputStream out = new FileOutputStream(output) - ) { - adjust(in, out); - } - } - - public static void adjust(RandomAccessFile in, OutputStream out) throws IOException { - - CentralFooter footer = new CentralFooter(in); - int outOff = 0; - long centralOff = unsigned(footer.centralDirectoryOffset); - CentralHeader[] centralHeaders = new CentralHeader[unsigned(footer.numEntries)]; - - // Loop through central directory entries - for (int i = 0; i < centralHeaders.length; ++i) { - // Read central header - in.seek(centralOff); - centralHeaders[i] = new CentralHeader(in); - centralOff = in.getFilePointer(); - - // Read local header - in.seek(unsigned(centralHeaders[i].localHeaderOffset)); - LocalHeader localHeader = new LocalHeader(in); - - // Make sure local and central headers matches, and strip out data descriptor flag - centralHeaders[i].localHeaderOffset = outOff; - centralHeaders[i].flags &= ~(1 << 3); - localHeader.flags = centralHeaders[i].flags; - localHeader.crc32 = centralHeaders[i].crc32; - localHeader.compressedSize = centralHeaders[i].compressedSize; - localHeader.uncompressedSize = centralHeaders[i].uncompressedSize; - localHeader.fileNameLength = centralHeaders[i].fileNameLength; - localHeader.filename = centralHeaders[i].filename; - - // Write local header - outOff += localHeader.write(out); - - // Copy data - int read; - long len = unsigned(localHeader.compressedSize); - outOff += len; - byte data[] = new byte[4096]; - while ((read = in.read(data, 0, - len < data.length ? (int) len : data.length)) > 0) { - out.write(data, 0, read); - len -= read; - } - } - - footer.centralDirectoryOffset = outOff; - - // Write central directory - outOff = 0; - for (CentralHeader header : centralHeaders) - outOff += header.write(out); - - // Write central directory record - footer.centralDirectorySize = outOff; - footer.write(out); - } - - public static short unsigned(byte n) { - return (short)(n & 0xff); - } - - public static int unsigned(short n) { - return n & 0xffff; - } - - public static long unsigned(int n) { - return n & 0xffffffffL; - } - - public static class CentralFooter { - - static final int MAGIC = 0x06054b50; - - int signature; - short diskNumber; - short centralDirectoryDiskNumber; - short numEntriesThisDisk; - short numEntries; - int centralDirectorySize; - int centralDirectoryOffset; - short zipCommentLength; - // byte[] comments; - - CentralFooter(RandomAccessFile file) throws IOException { - byte[] buffer = new byte[22]; - for (long i = file.length() - 4; i >= 0; --i) { - file.seek(i); - file.read(buffer, 0 ,4); - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - signature = buf.getInt(); - if (signature != MAGIC) { - continue; - } - file.read(buffer, 4, buffer.length - 4); - diskNumber = buf.getShort(); - centralDirectoryDiskNumber = buf.getShort(); - numEntriesThisDisk = buf.getShort(); - numEntries = buf.getShort(); - centralDirectorySize = buf.getInt(); - centralDirectoryOffset = buf.getInt(); - zipCommentLength = buf.getShort(); - break; - } - } - - int write(OutputStream out) throws IOException { - byte[] buffer = new byte[22]; - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - buf.putInt(signature); - buf.putShort(diskNumber); - buf.putShort(centralDirectoryDiskNumber); - buf.putShort(numEntriesThisDisk); - buf.putShort(numEntries); - buf.putInt(centralDirectorySize); - buf.putInt(centralDirectoryOffset); - buf.putShort((short) 0); // zipCommentLength - out.write(buffer); - return buffer.length; - } - } - - static class CentralHeader { - - static final int MAGIC = 0x02014b50; - - int signature; - short versionMadeBy; - short versionNeededToExtract; - short flags; - short compressionMethod; - short lastModFileTime; - short lastModFileDate; - int crc32; - int compressedSize; - int uncompressedSize; - short fileNameLength; - short extraFieldLength; - short fileCommentLength; - short diskNumberStart; - short internalFileAttributes; - int externalFileAttributes; - int localHeaderOffset; - byte[] filename; - // byte[] extra; - // byte[] comment; - - CentralHeader(RandomAccessFile file) throws IOException { - byte[] buffer = new byte[46]; - file.read(buffer); - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - signature = buf.getInt(); - if (signature != MAGIC) - throw new IOException(); - versionMadeBy = buf.getShort(); - versionNeededToExtract = buf.getShort(); - flags = buf.getShort(); - compressionMethod = buf.getShort(); - lastModFileTime = buf.getShort(); - lastModFileDate = buf.getShort(); - crc32 = buf.getInt(); - compressedSize = buf.getInt(); - uncompressedSize = buf.getInt(); - fileNameLength = buf.getShort(); - extraFieldLength = buf.getShort(); - fileCommentLength = buf.getShort(); - diskNumberStart = buf.getShort(); - internalFileAttributes = buf.getShort(); - externalFileAttributes = buf.getInt(); - localHeaderOffset = buf.getInt(); - filename = new byte[unsigned(fileNameLength)]; - file.read(filename); - file.skipBytes(unsigned(extraFieldLength) + unsigned(fileCommentLength)); - } - - int write(OutputStream out) throws IOException { - byte[] buffer = new byte[46]; - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - buf.putInt(signature); - buf.putShort(versionMadeBy); - buf.putShort(versionNeededToExtract); - buf.putShort(flags); - buf.putShort(compressionMethod); - buf.putShort(lastModFileTime); - buf.putShort(lastModFileDate); - buf.putInt(crc32); - buf.putInt(compressedSize); - buf.putInt(uncompressedSize); - buf.putShort(fileNameLength); - buf.putShort((short) 0); // extraFieldLength - buf.putShort((short) 0); // fileCommentLength - buf.putShort(diskNumberStart); - buf.putShort(internalFileAttributes); - buf.putInt(externalFileAttributes); - buf.putInt(localHeaderOffset); - out.write(buffer); - out.write(filename); - return buffer.length + filename.length; - } - } - - static class LocalHeader { - - static final int MAGIC = 0x04034b50; - - int signature; - short versionNeededToExtract; - short flags; - short compressionMethod; - short lastModFileTime; - short lastModFileDate; - int crc32; - int compressedSize; - int uncompressedSize; - short fileNameLength; - short extraFieldLength; - byte[] filename; - // byte[] extra; - - LocalHeader(RandomAccessFile file) throws IOException { - byte[] buffer = new byte[30]; - file.read(buffer); - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - signature = buf.getInt(); - if (signature != MAGIC) - throw new IOException(); - versionNeededToExtract = buf.getShort(); - flags = buf.getShort(); - compressionMethod = buf.getShort(); - lastModFileTime = buf.getShort(); - lastModFileDate = buf.getShort(); - crc32 = buf.getInt(); - compressedSize = buf.getInt(); - uncompressedSize = buf.getInt(); - fileNameLength = buf.getShort(); - extraFieldLength = buf.getShort(); - file.skipBytes(unsigned(fileNameLength) + unsigned(extraFieldLength)); - } - - int write(OutputStream out) throws IOException { - byte[] buffer = new byte[30]; - ByteBuffer buf = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN); - buf.putInt(signature); - buf.putShort(versionNeededToExtract); - buf.putShort(flags); - buf.putShort(compressionMethod); - buf.putShort(lastModFileTime); - buf.putShort(lastModFileDate); - buf.putInt(crc32); - buf.putInt(compressedSize); - buf.putInt(uncompressedSize); - buf.putShort(fileNameLength); - buf.putShort((short) 0); // extraFieldLength - out.write(buffer); - out.write(filename); - return buffer.length + filename.length; - } - } -} diff --git a/app/signing/src/main/java/com/topjohnwu/signing/ZipSigner.java b/app/signing/src/main/java/com/topjohnwu/signing/ZipSigner.java index 53b6b9f1f..357c72730 100644 --- a/app/signing/src/main/java/com/topjohnwu/signing/ZipSigner.java +++ b/app/signing/src/main/java/com/topjohnwu/signing/ZipSigner.java @@ -6,7 +6,6 @@ import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; -import java.io.OutputStream; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; @@ -28,20 +27,20 @@ public class ZipSigner { System.exit(2); } - private static void sign(JarMap input, OutputStream output) throws Exception { - sign(SignAPK.class.getResourceAsStream("/keys/testkey.x509.pem"), - SignAPK.class.getResourceAsStream("/keys/testkey.pk8"), input, output); + private static void sign(JarMap input, FileOutputStream output) throws Exception { + sign(SignApk.class.getResourceAsStream("/keys/testkey.x509.pem"), + SignApk.class.getResourceAsStream("/keys/testkey.pk8"), input, output); } private static void sign(InputStream certIs, InputStream keyIs, - JarMap input, OutputStream output) throws Exception { + JarMap input, FileOutputStream output) throws Exception { X509Certificate cert = CryptoUtils.readCertificate(certIs); PrivateKey key = CryptoUtils.readPrivateKey(keyIs); - SignAPK.signAndAdjust(cert, key, input, output); + SignApk.sign(cert, key, input, output); } private static void sign(String keyStore, String keyStorePass, String alias, String keyPass, - JarMap in, OutputStream out) throws Exception { + JarMap in, FileOutputStream out) throws Exception { KeyStore ks; try { ks = KeyStore.getInstance("JKS"); @@ -56,7 +55,7 @@ public class ZipSigner { } X509Certificate cert = (X509Certificate) ks.getCertificate(alias); PrivateKey key = (PrivateKey) ks.getKey(alias, keyPass.toCharArray()); - SignAPK.signAndAdjust(cert, key, in, out); + SignApk.sign(cert, key, in, out); } public static void main(String[] args) throws Exception { @@ -66,7 +65,7 @@ public class ZipSigner { Security.insertProviderAt(new BouncyCastleProvider(), 1); try (JarMap in = JarMap.open(args[args.length - 2], false); - OutputStream out = new FileOutputStream(args[args.length - 1])) { + FileOutputStream out = new FileOutputStream(args[args.length - 1])) { if (args.length == 2) { sign(in, out); } else if (args.length == 4) { diff --git a/app/signing/src/main/java/com/topjohnwu/signing/ZipUtils.java b/app/signing/src/main/java/com/topjohnwu/signing/ZipUtils.java new file mode 100644 index 000000000..0fd3a6960 --- /dev/null +++ b/app/signing/src/main/java/com/topjohnwu/signing/ZipUtils.java @@ -0,0 +1,136 @@ +package com.topjohnwu.signing; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Assorted ZIP format helpers. + * + *

NOTE: Most helper methods operating on {@code ByteBuffer} instances expect that the byte + * order of these buffers is little-endian. + */ +public abstract class ZipUtils { + + private static final int ZIP_EOCD_REC_MIN_SIZE = 22; + private static final int ZIP_EOCD_REC_SIG = 0x06054b50; + private static final int ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET = 12; + private static final int ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET = 16; + private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20; + + private static final int ZIP64_EOCD_LOCATOR_SIZE = 20; + private static final int ZIP64_EOCD_LOCATOR_SIG = 0x07064b50; + + private static final int UINT16_MAX_VALUE = 0xffff; + + private ZipUtils() { + } + + /** + * Returns the position at which ZIP End of Central Directory record starts in the provided + * buffer or {@code -1} if the record is not present. + * + *

NOTE: Byte order of {@code zipContents} must be little-endian. + */ + public static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents) { + assertByteOrderLittleEndian(zipContents); + + // ZIP End of Central Directory (EOCD) record is located at the very end of the ZIP archive. + // The record can be identified by its 4-byte signature/magic which is located at the very + // beginning of the record. A complication is that the record is variable-length because of + // the comment field. + // The algorithm for locating the ZIP EOCD record is as follows. We search backwards from + // end of the buffer for the EOCD record signature. Whenever we find a signature, we check + // the candidate record's comment length is such that the remainder of the record takes up + // exactly the remaining bytes in the buffer. The search is bounded because the maximum + // size of the comment field is 65535 bytes because the field is an unsigned 16-bit number. + + int archiveSize = zipContents.capacity(); + if (archiveSize < ZIP_EOCD_REC_MIN_SIZE) { + return -1; + } + int maxCommentLength = Math.min(archiveSize - ZIP_EOCD_REC_MIN_SIZE, UINT16_MAX_VALUE); + int eocdWithEmptyCommentStartPosition = archiveSize - ZIP_EOCD_REC_MIN_SIZE; + for (int expectedCommentLength = 0; expectedCommentLength < maxCommentLength; expectedCommentLength++) { + int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength; + if (zipContents.getInt(eocdStartPos) == ZIP_EOCD_REC_SIG) { + int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET); + if (actualCommentLength == expectedCommentLength) { + return eocdStartPos; + } + } + } + + return -1; + } + + /** + * Returns {@code true} if the provided buffer contains a ZIP64 End of Central Directory + * Locator. + * + *

NOTE: Byte order of {@code zipContents} must be little-endian. + */ + public static boolean isZip64EndOfCentralDirectoryLocatorPresent(ByteBuffer zipContents, int zipEndOfCentralDirectoryPosition) { + assertByteOrderLittleEndian(zipContents); + + // ZIP64 End of Central Directory Locator immediately precedes the ZIP End of Central + // Directory Record. + + int locatorPosition = zipEndOfCentralDirectoryPosition - ZIP64_EOCD_LOCATOR_SIZE; + if (locatorPosition < 0) { + return false; + } + + return zipContents.getInt(locatorPosition) == ZIP64_EOCD_LOCATOR_SIG; + } + + /** + * Returns the offset of the start of the ZIP Central Directory in the archive. + * + *

NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET); + } + + /** + * Sets the offset of the start of the ZIP Central Directory in the archive. + * + *

NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static void setZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory, long offset) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + setUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET, offset); + } + + /** + * Returns the size (in bytes) of the ZIP Central Directory. + * + *

NOTE: Byte order of {@code zipEndOfCentralDirectory} must be little-endian. + */ + public static long getZipEocdCentralDirectorySizeBytes(ByteBuffer zipEndOfCentralDirectory) { + assertByteOrderLittleEndian(zipEndOfCentralDirectory); + return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET); + } + + private static void assertByteOrderLittleEndian(ByteBuffer buffer) { + if (buffer.order() != ByteOrder.LITTLE_ENDIAN) { + throw new IllegalArgumentException("ByteBuffer byte order must be little endian"); + } + } + + private static int getUnsignedInt16(ByteBuffer buffer, int offset) { + return buffer.getShort(offset) & 0xffff; + } + + private static long getUnsignedInt32(ByteBuffer buffer, int offset) { + return buffer.getInt(offset) & 0xffffffffL; + } + + private static void setUnsignedInt32(ByteBuffer buffer, int offset, long value) { + if ((value < 0) || (value > 0xffffffffL)) { + throw new IllegalArgumentException("uint32 value of out range: " + value); + } + buffer.putInt(buffer.position() + offset, (int) value); + } +} diff --git a/app/src/main/java/com/topjohnwu/magisk/core/utils/Keygen.kt b/app/src/main/java/com/topjohnwu/magisk/core/utils/Keygen.kt index 625c246c3..cdf432cc9 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/utils/Keygen.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/utils/Keygen.kt @@ -39,7 +39,7 @@ class Keygen(context: Context) : CertKeyProvider { } private val start = Calendar.getInstance().apply { add(Calendar.MONTH, -3) } - private val end = Calendar.getInstance().apply { add(Calendar.YEAR, 30) } + private val end = start.apply { add(Calendar.YEAR, 30) } override val cert get() = provider.cert override val key get() = provider.key diff --git a/app/src/main/java/com/topjohnwu/magisk/core/utils/PatchAPK.kt b/app/src/main/java/com/topjohnwu/magisk/core/utils/PatchAPK.kt index af7502dd3..423a1df5d 100644 --- a/app/src/main/java/com/topjohnwu/magisk/core/utils/PatchAPK.kt +++ b/app/src/main/java/com/topjohnwu/magisk/core/utils/PatchAPK.kt @@ -13,7 +13,7 @@ import com.topjohnwu.magisk.data.network.GithubRawServices import com.topjohnwu.magisk.ktx.get import com.topjohnwu.magisk.ktx.writeTo import com.topjohnwu.signing.JarMap -import com.topjohnwu.signing.SignAPK +import com.topjohnwu.signing.SignApk import com.topjohnwu.superuser.Shell import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope @@ -29,10 +29,8 @@ import java.security.SecureRandom object PatchAPK { - private const val ALPHA = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - private const val DIGITS = "0123456789" - const val ALPHANUM = ALPHA + DIGITS - private const val ALPHANUMDOTS = "$ALPHANUM............" + private const val ALPHA = "abcdefghijklmnopqrstuvwxyz" + private const val ALPHADOTS = "$ALPHA....." private const val APP_ID = "com.topjohnwu.magisk" private const val APP_NAME = "Magisk Manager" @@ -48,7 +46,7 @@ object PatchAPK { next = if (prev == '.' || i == len - 1) { ALPHA[random.nextInt(ALPHA.length)] } else { - ALPHANUMDOTS[random.nextInt(ALPHANUMDOTS.length)] + ALPHADOTS[random.nextInt(ALPHADOTS.length)] } builder.append(next) prev = next @@ -96,7 +94,7 @@ object PatchAPK { // Write apk changes jar.getOutputStream(je).write(xml) val keys = Keygen(get()) - SignAPK.sign(keys.cert, keys.key, jar, FileOutputStream(out).buffered()) + SignApk.sign(keys.cert, keys.key, jar, FileOutputStream(out)) } catch (e: Exception) { Timber.e(e) return false