"""Lossless ZIP repacking and APK v2 RSA/SHA-256 signing, Python 3.9 stdlib. Spec: https://source.android.com/docs/security/features/apksigning/v2 Sections: APK Signing Block; APK Signature Scheme v2 Block; Integrity-protected contents; Verification. No verity algorithm is used, so no verity padding. """ import hashlib import io import json import math import os from pathlib import Path import re import secrets import struct import tempfile import zipfile import zlib import frame_host MAGIC = b'APK Sig Block 42' V2 = 0x7109871a ALG = 0x0103 SHA256_DER = bytes.fromhex('3031300d060960864801650304020105000420') def u32(n): return struct.pack('= n: raise ValueError('invalid RSA signature size/value') actual = pow(int.from_bytes(sig, 'big'), e, n).to_bytes(size, 'big') if actual != encoded_hash(data, size): raise ValueError('RSA signature mismatch') def certificate(key): name = sequence(der(0x31, sequence(bytes.fromhex('0603550403'), der(12, b'Frame Control APK signer')))) validity = sequence(der(0x17, b'200101000000Z'), der(0x18, b'21200101000000Z')) tbs = sequence(der(0xa0, integer(2)), integer(1), CERT_ALG, name, validity, name, public_key(key)) return sequence(tbs, CERT_ALG, der(3, b'\0' + rsa_sign(tbs, key))) def _prime(bits): small = (3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47) while True: n = secrets.randbits(bits) | (3 << (bits - 2)) | 1 if any(n % p == 0 for p in small) or (n - 1) % 65537 == 0: continue d, s = n - 1, 0 while d % 2 == 0: d //= 2 s += 1 for _ in range(40): # Miller-Rabin error bound <= 2^-80 x = pow(secrets.randbelow(n - 3) + 2, d, n) if x in (1, n - 1): continue for _ in range(s - 1): x = pow(x, 2, n) if x == n - 1: break else: break else: return n def signing_key(path=None): """Persistent identity in app data, never an evictable cache. Atomic publication. Hard-linking a fully written private temp file prevents concurrent first-use callers from selecting different identities or reading a partial key. """ path = Path(path) if path is not None else frame_host.data_dir('apk-signing-key.json') if not path.exists(): p, q = _prime(1024), _prime(1024) while q == p: q = _prime(1024) key = {'n': p * q, 'e': 65537, 'd': pow(65537, -1, math.lcm(p - 1, q - 1))} path.parent.mkdir(parents=True, exist_ok=True) fd, tmp = tempfile.mkstemp(prefix='.apk-key-', dir=str(path.parent)) try: with os.fdopen(fd, 'w') as f: json.dump(key, f) f.flush() os.fsync(f.fileno()) try: os.link(tmp, path) # never replaces a key another process wrote first except FileExistsError: pass except OSError: # no hard links (FAT/exFAT): plain rename if not path.exists(): os.replace(tmp, path) finally: if os.path.exists(tmp): os.unlink(tmp) os.chmod(path, 0o600) # Windows ignores this; the per-user app-data folder is the protection there key = json.loads(path.read_text()) if key['n'].bit_length() != 2048 or key['e'] != 65537: raise ValueError(f'invalid cached APK signing key; delete {path} to make a new one ' '(re-signed apps then need reinstalling)') rsa_verify(b'key check', rsa_sign(b'key check', key), key['n'], key['e']) return key def _eocd(data): # ZIP comments can contain the EOCD signature; accept only an exact EOF fit. for at in range(len(data) - 22, max(-1, len(data) - 65558), -1): if data[at:at + 4] == b'PK\5\6' and at + 22 + struct.unpack_from(' len(data): raise ValueError('truncated length prefix') size = struct.unpack_from(' len(data): raise ValueError('length prefix outside block') result.append(data[off:off + size]) off += size return result def _der_parts(data): result, off = [], 0 while off < len(data): start = off tag, size = data[off:off + 2] off += 2 if size & 128: count = size & 127 if not count or count > 4: raise ValueError('invalid DER length') size = int.from_bytes(data[off:off + count], 'big') off += count if off + size > len(data): raise ValueError('truncated DER') result.append((tag, data[off:off + size], data[start:off + size])) off += size return result def _cert_key(cert): outer = _der_parts(cert) if len(outer) != 1 or outer[0][0] != 0x30: raise ValueError('invalid certificate') fields = _der_parts(outer[0][1]) tbs = _der_parts(fields[0][1]) spki = tbs[6 if tbs[0][0] == 0xa0 else 5][2] pub = _der_parts(_der_parts(spki)[0][1]) if pub[0][2] != RSA_ALG or pub[1][1][:1] != b'\0': raise ValueError('certificate is not RSA') numbers = _der_parts(_der_parts(pub[1][1][1:])[0][1]) n, e = [int.from_bytes(item[1], 'big') for item in numbers] return n, e, spki def verify(path): """Verify this v2-only format; return True or raise ValueError on corruption. A valid signature establishes integrity, not trust in the APK publisher. """ data = Path(path).read_bytes() try: eo, cd = _eocd(data) if data[cd - 16:cd] != MAGIC: raise ValueError('no APK signing block') size = struct.unpack_from(' cd - 24: raise ValueError('invalid signing pair') ident = struct.unpack_from('= 0xffffffff: raise ValueError('ZIP64 APKs are unsupported') output.write(struct.pack('= 65535 or cd + len(directory) >= 0xffffffff: raise ValueError('ZIP64 APKs are unsupported') output.write(directory) output.write(struct.pack('