diff --git a/.gitattributes b/.gitattributes index b965efe99..a777ac7f7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -8,6 +8,7 @@ # Declare files that will always have CRLF line endings on checkout. *.cmd text eol=crlf +*.bat text eol=crlf # Denote all files that are truly binary and should not be modified. chromeos/** binary diff --git a/build.py b/build.py index 04f40ee64..4b45311c5 100755 --- a/build.py +++ b/build.py @@ -122,8 +122,17 @@ def build_apk(args): if proc.returncode != 0: error('Zipalign Magisk Manager failed!') - proc = subprocess.run('{} sign --ks {} --out {} {}'.format( - 'java -jar {}'.format(os.path.join('../ziptools/apksigner.jar')), + # Find apksigner.jar + apksigner = '' + for root, dirs, files in os.walk(os.path.join(os.environ['ANDROID_HOME'], 'build-tools', build_tool)): + if 'apksigner.jar' in files: + apksigner = os.path.join(root, 'apksigner.jar') + break + if not apksigner: + error('Cannot find apksigner.jar in Android SDK build tools') + + proc = subprocess.run('java -jar {} sign --ks {} --out {} {}'.format( + apksigner, os.path.join('..', 'release_signature.jks'), release, aligned), shell=True) if proc.returncode != 0: @@ -140,20 +149,33 @@ def build_apk(args): os.chdir('..') def sign_adjust_zip(unsigned, output): - header('* Signing / Adjusting Zip') - # Unsigned->signed - proc = subprocess.run(['java', '-jar', os.path.join('ziptools', 'signapk.jar'), - os.path.join('ziptools', 'public.certificate.x509.pem'), - os.path.join('ziptools', 'private.key.pk8'), unsigned, 'tmp_signed.zip']) - if proc.returncode != 0: - error('First sign flashable zip failed!') + zipsigner = os.path.join('ziptools', 'zipsigner', 'build', 'libs', 'zipsigner.jar') if os.name != 'nt' and not os.path.exists(os.path.join('ziptools', 'zipadjust')): + header('* Building zipadjust') # Compile zipadjust - proc = subprocess.run('gcc -o ziptools/zipadjust ziptools/src/*.c -lz', shell=True) + proc = subprocess.run('gcc -o ziptools/zipadjust ziptools/zipadjust_src/*.c -lz', shell=True) if proc.returncode != 0: error('Build zipadjust failed!') + if not os.path.exists(zipsigner): + header('* Building zipsigner.jar') + os.chdir(os.path.join('ziptools', 'zipsigner')) + proc = subprocess.run('{} shadowJar'.format(os.path.join('.', 'gradlew')), shell=True) + if proc.returncode != 0: + error('Build zipsigner.jar failed!') + os.chdir(os.path.join('..', '..')) + + header('* Signing / Adjusting Zip') + + publicKey = os.path.join('ziptools', 'public.certificate.x509.pem') + privateKey = os.path.join('ziptools', 'private.key.pk8') + + # Unsigned->signed + proc = subprocess.run(['java', '-jar', zipsigner, + publicKey, privateKey, unsigned, 'tmp_signed.zip']) + if proc.returncode != 0: + error('First sign flashable zip failed!') # Adjust zip proc = subprocess.run([os.path.join('ziptools', 'zipadjust'), 'tmp_signed.zip', 'tmp_adjusted.zip']) @@ -161,9 +183,8 @@ def sign_adjust_zip(unsigned, output): error('Adjust flashable zip failed!') # Adjusted -> output - proc = subprocess.run(['java', '-jar', os.path.join('ziptools', 'minsignapk.jar'), - os.path.join('ziptools', 'public.certificate.x509.pem'), - os.path.join('ziptools', 'private.key.pk8'), 'tmp_adjusted.zip', output]) + proc = subprocess.run(['java', '-jar', zipsigner, + "-m", publicKey, privateKey, 'tmp_adjusted.zip', output]) if proc.returncode != 0: error('Second sign flashable zip failed!') diff --git a/ziptools/apksigner.jar b/ziptools/apksigner.jar deleted file mode 100644 index b9017b06a..000000000 Binary files a/ziptools/apksigner.jar and /dev/null differ diff --git a/ziptools/minsignapk.jar b/ziptools/minsignapk.jar deleted file mode 100644 index cd38d23fa..000000000 Binary files a/ziptools/minsignapk.jar and /dev/null differ diff --git a/ziptools/signapk.jar b/ziptools/signapk.jar deleted file mode 100644 index 8435b757a..000000000 Binary files a/ziptools/signapk.jar and /dev/null differ diff --git a/ziptools/src/MinSignAPK.java b/ziptools/src/MinSignAPK.java deleted file mode 100644 index 78508175a..000000000 --- a/ziptools/src/MinSignAPK.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright (C) 2008 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. - */ -/* This is just a copy/paste/cut job from original SignAPK sources. This - * adaptation adds only the whole-file signature to a ZIP(jar,apk) file, and - * doesn't do any of the per-file signing, creating manifests, etc. This is - * useful when you've changed the structure itself of an existing (signed!) - * ZIP file, but the extracted contents are still identical. Using - * the normal SignAPK may re-arrange other things inside the ZIP, which may - * be unwanted behavior. This version only changes the ZIP's tail and keeps - * the rest the same - CF - */ - -package eu.chainfire.minsignapk; - -import java.io.ByteArrayOutputStream; -import java.io.DataInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.security.GeneralSecurityException; -import java.security.KeyFactory; -import java.security.PrivateKey; -import java.security.Signature; -import java.security.cert.CertificateFactory; -import java.security.cert.X509Certificate; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; -import java.security.spec.PKCS8EncodedKeySpec; - -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; - -public class MinSignAPK { - /** Write a .RSA file with a digital signature. */ - private static void writeSignatureBlock(Signature signature, X509Certificate publicKey, OutputStream out) - throws IOException, GeneralSecurityException { - SignerInfo signerInfo = new SignerInfo(new X500Name(publicKey.getIssuerX500Principal().getName()), - publicKey.getSerialNumber(), AlgorithmId.get("SHA1"), AlgorithmId.get("RSA"), signature.sign()); - - PKCS7 pkcs7 = new PKCS7(new AlgorithmId[] { AlgorithmId.get("SHA1") }, new ContentInfo(ContentInfo.DATA_OID, - null), new X509Certificate[] { publicKey }, new SignerInfo[] { signerInfo }); - - pkcs7.encodeSignedData(out); - } - - private static void signWholeOutputFile(byte[] zipData, OutputStream outputStream, X509Certificate publicKey, - PrivateKey privateKey) throws IOException, GeneralSecurityException { - - // 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. - 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"); - } - - Signature signature = Signature.getInstance("SHA1withRSA"); - signature.initSign(privateKey); - signature.update(zipData, 0, zipData.length - 2); - - 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); - writeSignatureBlock(signature, publicKey, temp); - 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 side. 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); - } - } - - outputStream.write(zipData, 0, zipData.length - 2); - outputStream.write(total_size & 0xff); - outputStream.write((total_size >> 8) & 0xff); - temp.writeTo(outputStream); - } - - private static PrivateKey readPrivateKey(File file) - throws IOException, GeneralSecurityException { - DataInputStream input = new DataInputStream(new FileInputStream(file)); - try { - byte[] bytes = new byte[(int) file.length()]; - input.read(bytes); - - // dont support encrypted keys atm - //KeySpec spec = decryptPrivateKey(bytes, file); - //if (spec == null) { - KeySpec spec = new PKCS8EncodedKeySpec(bytes); - //} - - try { - return KeyFactory.getInstance("RSA").generatePrivate(spec); - } catch (InvalidKeySpecException ex) { - return KeyFactory.getInstance("DSA").generatePrivate(spec); - } - } finally { - input.close(); - } - } - - private static X509Certificate readPublicKey(File file) - throws IOException, GeneralSecurityException { - FileInputStream input = new FileInputStream(file); - try { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - return (X509Certificate) cf.generateCertificate(input); - } finally { - input.close(); - } - } - - public static void main(String[] args) { - if (args.length < 4) { - System.out.println("MinSignAPK pemfile pk8file inzip outzip"); - System.out.println("- only adds whole-file signature to zip"); - return; - } - - String pemFile = args[0]; - String pk8File = args[1]; - String inFile = args[2]; - String outFile = args[3]; - - try { - X509Certificate publicKey = readPublicKey(new File(pemFile)); - PrivateKey privateKey = readPrivateKey(new File(pk8File)); - - InputStream fis = new FileInputStream(inFile); - byte[] buffer = new byte[(int)(new File(inFile)).length()]; - fis.read(buffer); - fis.close(); - - OutputStream fos = new FileOutputStream(outFile, false); - signWholeOutputFile(buffer, fos, publicKey, privateKey); - fos.close(); - } catch (Exception e) { - e.printStackTrace(); - } - } -} diff --git a/ziptools/src/zipadjust.c b/ziptools/zipadjust_src/zipadjust.c similarity index 100% rename from ziptools/src/zipadjust.c rename to ziptools/zipadjust_src/zipadjust.c diff --git a/ziptools/src/zipadjust.h b/ziptools/zipadjust_src/zipadjust.h similarity index 100% rename from ziptools/src/zipadjust.h rename to ziptools/zipadjust_src/zipadjust.h diff --git a/ziptools/src/zipadjust_run.c b/ziptools/zipadjust_src/zipadjust_run.c similarity index 100% rename from ziptools/src/zipadjust_run.c rename to ziptools/zipadjust_src/zipadjust_run.c diff --git a/ziptools/zipsigner/.gitignore b/ziptools/zipsigner/.gitignore new file mode 100644 index 000000000..87dc02017 --- /dev/null +++ b/ziptools/zipsigner/.gitignore @@ -0,0 +1,7 @@ +*.iml +.gradle +/local.properties +.idea/ +/build +*.hprof +app/.externalNativeBuild/ diff --git a/ziptools/zipsigner/build.gradle b/ziptools/zipsigner/build.gradle new file mode 100644 index 000000000..60879d23a --- /dev/null +++ b/ziptools/zipsigner/build.gradle @@ -0,0 +1,36 @@ +group 'com.topjohnwu' +version '1.0.0' + +apply plugin: 'java' +apply plugin: 'com.github.johnrengelman.shadow' + +sourceCompatibility = 1.8 + +jar { + manifest { + attributes 'Main-Class': 'com.topjohnwu.ZipSigner' + } +} + +shadowJar { + classifier = null + version = null +} + +buildscript { + repositories { + jcenter() + } + dependencies { + classpath 'com.github.jengelman.gradle.plugins:shadow:2.0.1' + } +} + +repositories { + mavenCentral() +} + +dependencies { + compile 'org.bouncycastle:bcprov-jdk15on:1.57' + compile 'org.bouncycastle:bcpkix-jdk15on:1.57' +} diff --git a/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.jar b/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..ccfe89381 Binary files /dev/null and b/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.jar differ diff --git a/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.properties b/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1d882d3e6 --- /dev/null +++ b/ziptools/zipsigner/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Thu Aug 24 10:35:40 CST 2017 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-3.5-rc-2-bin.zip diff --git a/ziptools/zipsigner/gradlew b/ziptools/zipsigner/gradlew new file mode 100755 index 000000000..4453ccea3 --- /dev/null +++ b/ziptools/zipsigner/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save ( ) { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=$(save "$@") + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/ziptools/zipsigner/gradlew.bat b/ziptools/zipsigner/gradlew.bat new file mode 100644 index 000000000..f9553162f --- /dev/null +++ b/ziptools/zipsigner/gradlew.bat @@ -0,0 +1,84 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ziptools/zipsigner/settings.gradle b/ziptools/zipsigner/settings.gradle new file mode 100644 index 000000000..a42023557 --- /dev/null +++ b/ziptools/zipsigner/settings.gradle @@ -0,0 +1,4 @@ +rootProject.name = 'zipsigner' +include 'apksigner' +rootProject.name = 'zipsigner' + diff --git a/ziptools/zipsigner/src/main/java/com/topjohnwu/ZipSigner.java b/ziptools/zipsigner/src/main/java/com/topjohnwu/ZipSigner.java new file mode 100644 index 000000000..bf7245314 --- /dev/null +++ b/ziptools/zipsigner/src/main/java/com/topjohnwu/ZipSigner.java @@ -0,0 +1,567 @@ +package com.topjohnwu; + +import org.bouncycastle.asn1.ASN1InputStream; +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.DEROutputStream; +import org.bouncycastle.asn1.cms.CMSObjectIdentifiers; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.cert.jcajce.JcaCertStore; +import org.bouncycastle.cms.CMSException; +import org.bouncycastle.cms.CMSProcessableByteArray; +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.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +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.security.DigestOutputStream; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.MessageDigest; +import java.security.PrivateKey; +import java.security.Provider; +import java.security.Security; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Enumeration; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.jar.Attributes; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.jar.Manifest; +import java.util.regex.Pattern; + +/* +* Modified from from AOSP(Marshmallow) SignAPK.java +* */ + +public class ZipSigner { + private static final String CERT_SF_NAME = "META-INF/CERT.SF"; + private static final String CERT_SIG_NAME = "META-INF/CERT.%s"; + + private static Provider sBouncyCastleProvider; + + // 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; + + /** + * Return one of USE_SHA1 or USE_SHA256 according to the signature + * algorithm specified in the cert. + */ + private static int getDigestAlgorithm(X509Certificate cert) { + String sigAlg = cert.getSigAlgName().toUpperCase(Locale.US); + if ("SHA1WITHRSA".equals(sigAlg) || + "MD5WITHRSA".equals(sigAlg)) { // see "HISTORICAL NOTE" above. + return USE_SHA1; + } else if (sigAlg.startsWith("SHA256WITH")) { + return USE_SHA256; + } else { + throw new IllegalArgumentException("unsupported signature algorithm \"" + sigAlg + + "\" in cert [" + cert.getSubjectDN()); + } + } + /** 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) { + return "SHA256withRSA"; + } else { + return "SHA1withRSA"; + } + } else if ("EC".equalsIgnoreCase(keyType)) { + return "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("unsupported key type: " + keyType); + } + } + // 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) + ")$"); + private static X509Certificate readPublicKey(InputStream input) + throws IOException, GeneralSecurityException { + try { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(input); + } finally { + input.close(); + } + } + + /** Read a PKCS#8 format private key. */ + private static PrivateKey readPrivateKey(InputStream input) + throws IOException, GeneralSecurityException { + try { + byte[] buffer = new byte[4096]; + int size = input.read(buffer); + byte[] bytes = Arrays.copyOf(buffer, size); + /* Check to see if this is in an EncryptedPrivateKeyInfo structure. */ + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(bytes); + /* + * Now it's in a PKCS#8 PrivateKeyInfo structure. Read its Algorithm + * OID and use that to construct a KeyFactory. + */ + ASN1InputStream bIn = new ASN1InputStream(new ByteArrayInputStream(spec.getEncoded())); + PrivateKeyInfo pki = PrivateKeyInfo.getInstance(bIn.readObject()); + String algOid = pki.getPrivateKeyAlgorithm().getAlgorithm().getId(); + return KeyFactory.getInstance(algOid).generatePrivate(spec); + } finally { + input.close(); + } + } + /** + * Add the hash(es) of every file to the manifest, creating it if + * necessary. + */ + private static Manifest addDigestsToManifest(JarFile jar, int hashes) + throws IOException, GeneralSecurityException { + Manifest input = jar.getManifest(); + Manifest output = new Manifest(); + Attributes main = output.getMainAttributes(); + if (input != null) { + main.putAll(input.getMainAttributes()); + } else { + 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) { + md_sha1 = MessageDigest.getInstance("SHA1"); + } + 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()) { + String name = entry.getName(); + if (!entry.isDirectory() && + (stripPattern == null || !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 = null; + if (input != null) attr = input.getAttributes(name); + attr = attr != null ? new Attributes(attr) : new Attributes(); + if (md_sha1 != null) { + attr.putValue("SHA1-Digest", + new String(Base64.encode(md_sha1.digest()), "ASCII")); + } + if (md_sha256 != null) { + attr.putValue("SHA-256-Digest", + new String(Base64.encode(md_sha256.digest()), "ASCII")); + } + output.getEntries().put(name, attr); + } + } + return output; + } + + /** 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; + } + } + /** Write a .SF file with a digest of the specified manifest. */ + 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(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. + print.print("Name: " + entry.getKey() + "\r\n"); + for (Map.Entry att : entry.getValue().entrySet()) { + print.print(att.getKey() + ": " + att.getValue() + "\r\n"); + } + print.print("\r\n"); + print.flush(); + Attributes sfAttr = new Attributes(); + sfAttr.putValue(hash == USE_SHA256 ? "SHA-256-Digest" : "SHA1-Digest-Manifest", + 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. + // As a workaround, add an extra CRLF in this case. + if ((cout.size() % 1024) == 0) { + cout.write('\r'); + cout.write('\n'); + } + } + /** Sign data and write the digital signature to 'out'. */ + private static void writeSignatureBlock( + CMSTypedData data, X509Certificate publicKey, PrivateKey privateKey, + OutputStream out) + throws IOException, + CertificateEncodingException, + OperatorCreationException, + CMSException { + ArrayList certList = new ArrayList<>(1); + certList.add(publicKey); + JcaCertStore certs = new JcaCertStore(certList); + CMSSignedDataGenerator gen = new CMSSignedDataGenerator(); + ContentSigner signer = new JcaContentSignerBuilder(getSignatureAlgorithm(publicKey)) + .setProvider(sBouncyCastleProvider) + .build(privateKey); + gen.addSignerInfoGenerator( + new JcaSignerInfoGeneratorBuilder( + new JcaDigestCalculatorProviderBuilder() + .setProvider(sBouncyCastleProvider) + .build()) + .setDirectSignature(true) + .build(signer, publicKey)); + gen.addCertificates(certs); + CMSSignedData sigData = gen.generate(data, false); + ASN1InputStream asn1 = new ASN1InputStream(sigData.getEncoded()); + DEROutputStream dos = new DEROutputStream(out); + dos.writeObject(asn1.readObject()); + } + /** + * Copy all the files in a manifest from input to output. We set + * the modification times in the output to a fixed time, so as to + * reduce variation in the output file and make incremental OTAs + * more efficient. + */ + private static void copyFiles(Manifest manifest, JarFile in, JarOutputStream out, + long timestamp, int alignment) 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; + if (inEntry.getMethod() != JarEntry.STORED) continue; + // Preserve the STORED method of the input entry. + outEntry = new JarEntry(inEntry); + outEntry.setTime(timestamp); + // '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'. + offset += JarFile.LOCHDR + outEntry.getName().length(); + if (firstEntry) { + // The first entry in a jar file has an extra field of + // four bytes that you can't get rid of; any extra + // data you specify in the JarEntry is appended to + // these forced four bytes. This is JAR_MAGIC in + // JarOutputStream; the bytes are 0xfeca0000. + offset += 4; + firstEntry = false; + } + 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); + 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); + offset += num; + } + 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; + 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); + } + out.flush(); + } + } + + // 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 File file; + private ASN1ObjectIdentifier type; + private byte[] buffer; + int bufferSize = 0; + + CMSProcessableFile(File file) { + this.file = file; + type = new ASN1ObjectIdentifier(CMSObjectIdentifiers.data.getId()); + buffer = new byte[4096]; + } + + @Override + public ASN1ObjectIdentifier getContentType() { + return type; + } + + @Override + public void write(OutputStream out) throws IOException, CMSException { + FileInputStream input = new FileInputStream(file); + long len = file.length() - 2; + while ((bufferSize = input.read(buffer)) > 0) { + if (len <= bufferSize) { + out.write(buffer, 0, (int) len); + break; + } else { + out.write(buffer, 0, bufferSize); + } + len -= bufferSize; + } + } + + @Override + public Object getContent() { + return file; + } + + byte[] getTail() { + return Arrays.copyOfRange(buffer, 0, bufferSize); + } + } + + 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); + } + private static void signFile(Manifest manifest, JarFile inputJar, + X509Certificate publicKey, PrivateKey privateKey, + JarOutputStream outputJar) + throws Exception { + // Assume the certificate is valid for at least an hour. + long timestamp = publicKey.getNotBefore().getTime() + 3600L * 1000; + // 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(publicKey)); + byte[] signedData = baos.toByteArray(); + outputJar.write(signedData); + // CERT.{EC,RSA} / CERT#.{EC,RSA} + final String keyType = publicKey.getPublicKey().getAlgorithm(); + je = new JarEntry(String.format(CERT_SIG_NAME, keyType)); + je.setTime(timestamp); + outputJar.putNextEntry(je); + writeSignatureBlock(new CMSProcessableByteArray(signedData), + publicKey, privateKey, outputJar); + } + + public static void main(String[] args) { + boolean minSign = false; + int argStart = 0; + + if (args.length < 4) { + System.err.println("Usage: zipsigner [-m] publickey.x509[.pem] privatekey.pk8 input.jar output.jar"); + System.exit(2); + } + + if (args[0].equals("-m")) { + minSign = true; + argStart = 1; + } + + sBouncyCastleProvider = new BouncyCastleProvider(); + Security.insertProviderAt(sBouncyCastleProvider, 1); + + File pubKey = new File(args[argStart]); + File privKey = new File(args[argStart + 1]); + File input = new File(args[argStart + 2]); + File output = new File(args[argStart + 3]); + + int alignment = 4; + JarFile inputJar = null; + FileOutputStream outputFile = null; + int hashes = 0; + try { + X509Certificate publicKey = readPublicKey(new FileInputStream(pubKey)); + hashes |= getDigestAlgorithm(publicKey); + + // 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 = publicKey.getNotBefore().getTime() + 3600L * 1000; + PrivateKey privateKey = readPrivateKey(new FileInputStream(privKey)); + + outputFile = new FileOutputStream(output); + if (minSign) { + ZipSigner.signWholeFile(input, publicKey, privateKey, outputFile); + } else { + inputJar = new JarFile(input, false); // Don't verify. + JarOutputStream outputJar = new JarOutputStream(outputFile); + // 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(inputJar, hashes); + copyFiles(manifest, inputJar, outputJar, timestamp, alignment); + signFile(manifest, inputJar, publicKey, privateKey, outputJar); + outputJar.close(); + } + } catch (Exception e) { + e.printStackTrace(); + System.exit(1); + } finally { + try { + if (inputJar != null) inputJar.close(); + if (outputFile != null) outputFile.close(); + } catch (IOException e) { + e.printStackTrace(); + System.exit(1); + } + } + } +} \ No newline at end of file