diff --git a/README.md b/README.md index 9b191cd..5cd34a2 100644 --- a/README.md +++ b/README.md @@ -72,8 +72,10 @@ tools/setup_toolchain.sh # once: fetches aapt2, the API 8 android.jar, buil ``` `build.sh` runs `aapt2` (resources), `javac` (Java 8 bytecode checked against the API 8 -`android.jar`), `dx` (dex 035), `jarsigner` (v1 signature with SHA-1, which Android < 4.3 requires) -and `tools/zipalign.py`. The debug key in `signing/` (password `minamp123`) is what this APK is +`android.jar`), `dx` (dex 035) and `tools/signapk.py`, which writes the v1 signature in the +classic Android SignApk format and aligns the APK. (Modern `jarsigner` output does *not* install on +Android 4.2 and older: it names digests `SHA-1-Digest` and labels the PKCS#7 signer algorithm in a +way old `JarUtils` can't read, so the phone treats the APK as unsigned.) The debug key in `signing/` (password `minamp123`) is what this APK is signed with, so rebuilt APKs install over it. Tests (desktop JVM, no device): diff --git a/build.sh b/build.sh index ccdcc8c..bec1f4f 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,6 @@ #!/bin/bash # Builds Minamp.apk for Android 2.2+ without the Android SDK/Gradle: -# aapt2 (resources) -> javac (Java 8 bytecode vs android-8 API) -> dx (dex 035) -> jarsigner (v1/SHA-1) -> zipalign. +# aapt2 (resources) -> javac (Java 8 bytecode vs android-8 API) -> dx (dex 035) -> signapk.py (v1/SHA-1 + zipalign). set -euo pipefail cd "$(dirname "$0")" T=${TOOLS:-$PWD/toolchain} @@ -28,8 +28,6 @@ java -Xmx2g -jar $T/dx.jar --dex --output=$OUT/classes.dex $OUT/classes 2>&1 | g echo "== package" cp $OUT/base.apk $OUT/unsigned.apk (cd $OUT && zip -q unsigned.apk classes.dex) -jarsigner -keystore ${KEYSTORE:-$T/minamp.keystore} -storepass minamp123 -keypass minamp123 \ - -digestalg SHA-1 -sigalg SHA1withRSA -signedjar $OUT/signed.apk $OUT/unsigned.apk minamp 2>&1 \ - | grep -v -E '^Picked up|^$|Warning|risk|self-signed|POSIX|ignored when signing|not protected' || true -python3 tools/zipalign.py $OUT/signed.apk $OUT/Minamp.apk +# v1 signature in the classic Android SignApk format (jarsigner's modern output won't install on Android <= 4.2) +python3 tools/signapk.py ${KEYSTORE:-$T/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $OUT/Minamp.apk ls -la $OUT/Minamp.apk diff --git a/tools/signapk.py b/tools/signapk.py new file mode 100644 index 0000000..733fe82 --- /dev/null +++ b/tools/signapk.py @@ -0,0 +1,146 @@ +"""v1 (JAR) APK signer in the format Android's own SignApk used in the 2.x era, plus zipalign. + +Why not jarsigner: modern JDKs write "SHA-1-Digest" entries (Android <= 4.2 only looks for +"SHA1-Digest"/"SHA-Digest") and put sha1WithRSAEncryption in the PKCS#7 SignerInfo, which old +JarUtils can't map. Either makes Android 2.x treat the APK as unsigned and refuse to install it. + +Usage: signapk.py +""" +import base64 +import hashlib +import struct +import sys +import zipfile + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.serialization import pkcs12 + +ALIGN = 4 +FIXED_TIME = (2008, 1, 1, 0, 0, 0) + + +# ---------------------------------------------------------------- minimal DER + +def der_len(n): + if n < 0x80: + return bytes([n]) + b = n.to_bytes((n.bit_length() + 7) // 8, "big") + return bytes([0x80 | len(b)]) + b + + +def tlv(tag, body): + return bytes([tag]) + der_len(len(body)) + body + + +def seq(*parts): + return tlv(0x30, b"".join(parts)) + + +def set_of(*parts): + return tlv(0x31, b"".join(sorted(parts))) + + +def oid(dotted): + nums = [int(x) for x in dotted.split(".")] + body = bytes([nums[0] * 40 + nums[1]]) + for n in nums[2:]: + chunk = [n & 0x7F] + n >>= 7 + while n: + chunk.append(0x80 | (n & 0x7F)) + n >>= 7 + body += bytes(reversed(chunk)) + return tlv(0x06, body) + + +def integer(n): + b = n.to_bytes((n.bit_length() + 8) // 8 or 1, "big", signed=True) + return tlv(0x02, b) + + +NULL = b"\x05\x00" +OID_SHA1 = "1.3.14.3.2.26" +OID_RSA = "1.2.840.113549.1.1.1" +OID_DATA = "1.2.840.113549.1.7.1" +OID_SIGNED_DATA = "1.2.840.113549.1.7.2" + + +def pkcs7_detached(sf_bytes, key, cert): + signature = key.sign(sf_bytes, padding.PKCS1v15(), hashes.SHA1()) + cert_der = cert.public_bytes(serialization.Encoding.DER) + sha1_alg = seq(oid(OID_SHA1), NULL) + signer_info = seq( + integer(1), + seq(cert.issuer.public_bytes(), integer(cert.serial_number)), + sha1_alg, + seq(oid(OID_RSA), NULL), # rsaEncryption, as old JarUtils expects + tlv(0x04, signature), + ) + signed_data = seq( + integer(1), + set_of(sha1_alg), + seq(oid(OID_DATA)), # detached: content is META-INF/CERT.SF + tlv(0xA0, cert_der), # [0] IMPLICIT certificates + set_of(signer_info), + ) + return seq(oid(OID_SIGNED_DATA), tlv(0xA0, signed_data)) + + +# ---------------------------------------------------------------- manifest text + +def b64sha1(data): + return base64.b64encode(hashlib.sha1(data).digest()).decode() + + +def wrap(line): + """JAR manifest lines are at most 72 bytes; continuation lines start with a space.""" + raw = line.encode("utf-8") + out = [raw[:70]] + raw = raw[70:] + while raw: + out.append(b" " + raw[:69]) + raw = raw[69:] + return b"\r\n".join(out) + b"\r\n" + + +def sign(p12_path, password, src, dst): + key, cert, _ = pkcs12.load_key_and_certificates(open(p12_path, "rb").read(), password.encode()) + zin = zipfile.ZipFile(src) + entries = [i for i in zin.infolist() + if not i.filename.startswith("META-INF/") and not i.is_dir()] + data = {i.filename: zin.read(i.filename) for i in entries} + + manifest = b"Manifest-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n\r\n" + sf_entries = b"" + for i in entries: + section = wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(data[i.filename])) + b"\r\n" + manifest += section + sf_entries += wrap("Name: " + i.filename) + wrap("SHA1-Digest: " + b64sha1(section)) + b"\r\n" + sf = (b"Signature-Version: 1.0\r\nCreated-By: 1.0 (Android SignApk)\r\n" + + wrap("SHA1-Digest-Manifest: " + b64sha1(manifest)) + b"\r\n" + sf_entries) + rsa = pkcs7_detached(sf, key, cert) + + with zipfile.ZipFile(dst, "w") as zout: + def put(name, payload, compress): + zi = zipfile.ZipInfo(name, date_time=FIXED_TIME) + zi.compress_type = compress + zi.external_attr = 0o644 << 16 + zi.create_system = 0 + extra = b"" + if compress == zipfile.ZIP_STORED: + base = zout.fp.tell() + 30 + len(name.encode("utf-8")) + 6 + pad = (ALIGN - base % ALIGN) % ALIGN + extra = struct.pack("