Private
Public Access
Sign APK in classic SignApk format so it installs on Android <= 4.2
Replace jarsigner + zipalign.py with tools/signapk.py, which writes the v1 signature the way old JarUtils expects and aligns the APK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0df8124629
commit
948d1198ad
3 files changed
+153
-7
No files matched your search
@@ -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
|
`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)
|
`android.jar`), `dx` (dex 035) and `tools/signapk.py`, which writes the v1 signature in the
|
||||||
and `tools/zipalign.py`. The debug key in `signing/` (password `minamp123`) is what this APK is
|
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.
|
signed with, so rebuilt APKs install over it.
|
||||||
|
|
||||||
Tests (desktop JVM, no device):
|
Tests (desktop JVM, no device):
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Builds Minamp.apk for Android 2.2+ without the Android SDK/Gradle:
|
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "$0")"
|
cd "$(dirname "$0")"
|
||||||
T=${TOOLS:-$PWD/toolchain}
|
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"
|
echo "== package"
|
||||||
cp $OUT/base.apk $OUT/unsigned.apk
|
cp $OUT/base.apk $OUT/unsigned.apk
|
||||||
(cd $OUT && zip -q unsigned.apk classes.dex)
|
(cd $OUT && zip -q unsigned.apk classes.dex)
|
||||||
jarsigner -keystore ${KEYSTORE:-$T/minamp.keystore} -storepass minamp123 -keypass minamp123 \
|
# v1 signature in the classic Android SignApk format (jarsigner's modern output won't install on Android <= 4.2)
|
||||||
-digestalg SHA-1 -sigalg SHA1withRSA -signedjar $OUT/signed.apk $OUT/unsigned.apk minamp 2>&1 \
|
python3 tools/signapk.py ${KEYSTORE:-$T/minamp.keystore} ${KEYPASS:-minamp123} $OUT/unsigned.apk $OUT/Minamp.apk
|
||||||
| 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
|
|
||||||
ls -la $OUT/Minamp.apk
|
ls -la $OUT/Minamp.apk
|
||||||
@@ -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 <keystore.p12> <password> <in.apk> <out.apk>
|
||||||
|
"""
|
||||||
|
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("<HHH", 0xD935, 2 + pad, ALIGN) + b"\0" * pad
|
||||||
|
zi.extra = extra
|
||||||
|
zout.writestr(zi, payload)
|
||||||
|
|
||||||
|
put("META-INF/MANIFEST.MF", manifest, zipfile.ZIP_DEFLATED)
|
||||||
|
put("META-INF/CERT.SF", sf, zipfile.ZIP_DEFLATED)
|
||||||
|
put("META-INF/CERT.RSA", rsa, zipfile.ZIP_DEFLATED)
|
||||||
|
for i in entries:
|
||||||
|
put(i.filename, data[i.filename], i.compress_type)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sign(*sys.argv[1:5])
|
||||||
Reference in new issue
Block a user