diff --git a/fdroidserver/apksigcopier.py b/fdroidserver/apksigcopier.py index 321aec52..b6a89ad5 100644 --- a/fdroidserver/apksigcopier.py +++ b/fdroidserver/apksigcopier.py @@ -1,26 +1,29 @@ #!/usr/bin/python3 # encoding: utf-8 -# SPDX-FileCopyrightText: 2022 FC Stegerman +# SPDX-FileCopyrightText: 2023 FC Stegerman # SPDX-License-Identifier: GPL-3.0-or-later # -- ; {{{1 # # File : apksigcopier # Maintainer : FC Stegerman -# Date : 2022-11-01 +# Date : 2023-02-08 # -# Copyright : Copyright (C) 2022 FC Stegerman -# Version : v1.1.0 +# Copyright : Copyright (C) 2023 FC Stegerman +# Version : v1.1.1 # License : GPLv3+ # # -- ; }}}1 """ -Copy/extract/patch android apk signatures. +copy/extract/patch android apk signatures & compare apks apksigcopier is a tool for copying android APK signatures from a signed APK to an unsigned one (in order to verify reproducible builds). +It can also be used to compare two APKs with different signatures; this requires +apksigner. + CLI === @@ -28,27 +31,33 @@ CLI $ apksigcopier extract [OPTIONS] SIGNED_APK OUTPUT_DIR $ apksigcopier patch [OPTIONS] METADATA_DIR UNSIGNED_APK OUTPUT_APK $ apksigcopier copy [OPTIONS] SIGNED_APK UNSIGNED_APK OUTPUT_APK +$ apksigcopier compare [OPTIONS] FIRST_APK SECOND_APK The following environment variables can be set to 1, yes, or true to override the default behaviour: +* set APKSIGCOPIER_EXCLUDE_ALL_META=1 to exclude all metadata files * set APKSIGCOPIER_COPY_EXTRA_BYTES=1 to copy extra bytes after data (e.g. a v2 sig) +* set APKSIGCOPIER_SKIP_REALIGNMENT=1 to skip realignment of ZIP entries API === ->> from apksigcopier import do_extract, do_patch, do_copy +>> from apksigcopier import do_extract, do_patch, do_copy, do_compare >> do_extract(signed_apk, output_dir, v1_only=NO) >> do_patch(metadata_dir, unsigned_apk, output_apk, v1_only=NO) >> do_copy(signed_apk, unsigned_apk, output_apk, v1_only=NO) +>> do_compare(first_apk, second_apk, unsigned=False) You can use False, None, and True instead of NO, AUTO, and YES respectively. The following global variables (which default to False), can be set to override the default behaviour: +* set exclude_all_meta=True to exclude all metadata files * set copy_extra_bytes=True to copy extra bytes after data (e.g. a v2 sig) +* set skip_realignment=True to skip realignment of ZIP entries """ import glob @@ -56,17 +65,25 @@ import json import os import re import struct +import subprocess +import sys +import tempfile import zipfile import zlib from collections import namedtuple -from typing import Any, BinaryIO, Dict, Iterable, Iterator, Literal, Optional, Tuple, Union +from typing import Any, BinaryIO, Callable, Dict, Iterable, Iterator, Optional, Tuple, Union -__version__ = "1.1.0" +__version__ = "1.1.1" NAME = "apksigcopier" +if sys.version_info >= (3, 8): + from typing import Literal + NoAutoYes = Literal["no", "auto", "yes"] +else: + NoAutoYes = str + DateTime = Tuple[int, int, int, int, int, int] -NoAutoYes = Literal["no", "auto", "yes"] NoAutoYesBoolNone = Union[NoAutoYes, bool, None] ZipInfoDataPairs = Iterable[Tuple[zipfile.ZipInfo, bytes]] @@ -75,7 +92,9 @@ NOAUTOYES: Tuple[NoAutoYes, NoAutoYes, NoAutoYes] = ("no", "auto", "yes") NO, AUTO, YES = NOAUTOYES APK_META = re.compile(r"^META-INF/([0-9A-Za-z_-]+\.(SF|RSA|DSA|EC)|MANIFEST\.MF)$") META_EXT: Tuple[str, ...] = ("SF", "RSA|DSA|EC", "MF") +COPY_EXCLUDE: Tuple[str, ...] = ("META-INF/MANIFEST.MF",) DATETIMEZERO: DateTime = (1980, 0, 0, 0, 0, 0) +VERIFY_CMD: Tuple[str, ...] = ("apksigner", "verify") ################################################################################ # @@ -107,7 +126,9 @@ VALID_ZIP_META = dict( ZipData = namedtuple("ZipData", ("cd_offset", "eocd_offset", "cd_and_eocd")) +exclude_all_meta = False # exclude all metadata files in copy_apk() copy_extra_bytes = False # copy extra bytes after data in copy_apk() +skip_realignment = False # skip realignment of ZIP entries in copy_apk() class APKSigCopierError(Exception): @@ -132,14 +153,15 @@ class ReproducibleZipInfo(zipfile.ZipInfo): _override: Dict[str, Any] = {} - def __init__(self, zinfo, **override): # pylint: disable=W0231 + def __init__(self, zinfo: zipfile.ZipInfo, **override: Any) -> None: + # pylint: disable=W0231 if override: self._override = {**self._override, **override} for k in self.__slots__: if hasattr(zinfo, k): setattr(self, k, getattr(zinfo, k)) - def __getattribute__(self, name): + def __getattribute__(self, name: str) -> Any: if name != "_override": try: return self._override[name] @@ -167,7 +189,7 @@ class APKZipInfo(ReproducibleZipInfo): def noautoyes(value: NoAutoYesBoolNone) -> NoAutoYes: """ - Turn False into NO, None into AUTO, and True into YES. + Turns False into NO, None into AUTO, and True into YES. >>> from apksigcopier import noautoyes, NO, AUTO, YES >>> noautoyes(False) == NO == noautoyes(NO) @@ -190,24 +212,84 @@ def noautoyes(value: NoAutoYesBoolNone) -> NoAutoYes: def is_meta(filename: str) -> bool: """ - Check whether filename is a JAR metadata file. - - This is true when filename is a v1 (JAR) signature file (.SF), signature - block file (.RSA, .DSA, or .EC), or manifest (MANIFEST.MF). + Returns whether filename is a v1 (JAR) signature file (.SF), signature block + file (.RSA, .DSA, or .EC), or manifest (MANIFEST.MF). See https://docs.oracle.com/javase/tutorial/deployment/jar/intro.html + + >>> from apksigcopier import is_meta + >>> is_meta("classes.dex") + False + >>> is_meta("META-INF/CERT.SF") + True + >>> is_meta("META-INF/CERT.RSA") + True + >>> is_meta("META-INF/MANIFEST.MF") + True + >>> is_meta("META-INF/OOPS") + False + """ return APK_META.fullmatch(filename) is not None def exclude_from_copying(filename: str) -> bool: """ - Check whether to exclude a file during copy_apk(). + Returns whether to exclude a file during copy_apk(). + + Excludes filenames in COPY_EXCLUDE (i.e. MANIFEST.MF) by default; when + exclude_all_meta is set to True instead, excludes all metadata files as + matched by is_meta(). + + Directories are always excluded. + + >>> import apksigcopier + >>> from apksigcopier import exclude_from_copying + >>> exclude_from_copying("classes.dex") + False + >>> exclude_from_copying("foo/") + True + >>> exclude_from_copying("META-INF/") + True + >>> exclude_from_copying("META-INF/MANIFEST.MF") + True + >>> exclude_from_copying("META-INF/CERT.SF") + False + >>> exclude_from_copying("META-INF/OOPS") + False + + >>> apksigcopier.exclude_all_meta = True + >>> exclude_from_copying("classes.dex") + False + >>> exclude_from_copying("META-INF/") + True + >>> exclude_from_copying("META-INF/MANIFEST.MF") + True + >>> exclude_from_copying("META-INF/CERT.SF") + True + >>> exclude_from_copying("META-INF/OOPS") + False - Fdroidserver always wants JAR Signature files to be excluded, so - it excludes all metadata files as matched by is_meta(). """ - return is_meta(filename) + return exclude_meta(filename) if exclude_all_meta else exclude_default(filename) + + +def exclude_default(filename: str) -> bool: + """ + Like exclude_from_copying(); excludes directories and filenames in + COPY_EXCLUDE (i.e. MANIFEST.MF). + """ + return is_directory(filename) or filename in COPY_EXCLUDE + + +def exclude_meta(filename: str) -> bool: + """Like exclude_from_copying(); excludes directories and all metadata files.""" + return is_directory(filename) or is_meta(filename) + + +def is_directory(filename: str) -> bool: + """ZIP entries with filenames that end with a '/' are directories.""" + return filename.endswith("/") ################################################################################ @@ -247,6 +329,27 @@ def zipflinger_virtual_entry(size: int) -> bytes: ) + int.to_bytes(size - 30, 2, "little") + b"\x00" * (size - 30) +def detect_zfe(apkfile: str) -> Optional[int]: + """ + Detect zipflinger virtual entry. + + Returns the size of the virtual entry if found, None otherwise. + + Raises ZipError if the size is less than 30 or greater than 4096, or the + data isn't all zeroes. + """ + with open(apkfile, "rb") as fh: + zfe_start = zipflinger_virtual_entry(30)[:28] # w/o len(extra) + if fh.read(28) == zfe_start: + zfe_size = 30 + int.from_bytes(fh.read(2), "little") + if not (30 <= zfe_size <= 4096): + raise ZipError("Unsupported virtual entry size") + if not fh.read(zfe_size - 30) == b"\x00" * (zfe_size - 30): + raise ZipError("Unsupported virtual entry data") + return zfe_size + return None + + ################################################################################ # # https://en.wikipedia.org/wiki/ZIP_(file_format) @@ -282,22 +385,73 @@ def zipflinger_virtual_entry(size: int) -> bytes: # FIXME: handle utf8 filenames w/o utf8 flag (as produced by zipflinger)? # https://android.googlesource.com/platform/tools/apksig # src/main/java/com/android/apksig/ApkSigner.java -def copy_apk(unsigned_apk: str, output_apk: str, *, zfe_size: Optional[int] = None) -> DateTime: +def copy_apk(unsigned_apk: str, output_apk: str, *, + copy_extra: Optional[bool] = None, + exclude: Optional[Callable[[str], bool]] = None, + realign: Optional[bool] = None, + zfe_size: Optional[int] = None) -> DateTime: """ Copy APK like apksigner would, excluding files matched by exclude_from_copying(). Adds a zipflinger virtual entry of zfe_size bytes if one is not already present and zfe_size is not None. + Returns max date_time. + The following global variables (which default to False), can be set to override the default behaviour: + * set exclude_all_meta=True to exclude all metadata files * set copy_extra_bytes=True to copy extra bytes after data (e.g. a v2 sig) + * set skip_realignment=True to skip realignment of ZIP entries + + The default behaviour can also be changed using the keyword-only arguments + exclude, copy_extra, and realign; these take precedence over the global + variables when not None. NB: exclude is a callable, not a bool; realign is + the inverse of skip_realignment. + + >>> import apksigcopier, os, zipfile + >>> apk = "test/apks/apks/golden-aligned-in.apk" + >>> with zipfile.ZipFile(apk, "r") as zf: + ... infos_in = zf.infolist() + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... out = os.path.join(tmpdir, "out.apk") + ... apksigcopier.copy_apk(apk, out) + ... with zipfile.ZipFile(out, "r") as zf: + ... infos_out = zf.infolist() + (2017, 5, 15, 11, 28, 40) + >>> for i in infos_in: + ... print(i.filename) + META-INF/ + META-INF/MANIFEST.MF + AndroidManifest.xml + classes.dex + temp.txt + lib/armeabi/fake.so + resources.arsc + temp2.txt + >>> for i in infos_out: + ... print(i.filename) + AndroidManifest.xml + classes.dex + temp.txt + lib/armeabi/fake.so + resources.arsc + temp2.txt + >>> infos_in[2] + + >>> infos_out[0] + + >>> repr(infos_in[2:]) == repr(infos_out) + True - Returns - ------- - max date_time """ + if copy_extra is None: + copy_extra = copy_extra_bytes + if exclude is None: + exclude = exclude_from_copying + if realign is None: + realign = not skip_realignment with zipfile.ZipFile(unsigned_apk, "r") as zf: infos = zf.infolist() zdata = zip_data(unsigned_apk) @@ -318,15 +472,16 @@ def copy_apk(unsigned_apk: str, output_apk: str, *, zfe_size: Optional[int] = No raise ZipError("Expected local file header signature") n, m = struct.unpack(" bytes: +def _realign_zip_entry(info: zipfile.ZipInfo, hdr: bytes, n: int, m: int, + off_o: int, pad_like_apksigner: bool = True) -> bytes: align = 4096 if info.filename.endswith(".so") else 4 old_off = 30 + n + m + info.header_offset new_off = 30 + n + m + off_o - old_xtr = info.extra + old_xtr = hdr[30 + n:30 + n + m] new_xtr = b"" while len(old_xtr) >= 4: hdr_id, size = struct.unpack(" Iterator[Tuple[zipfile.ZipInfo, bytes]]: Extract v1 signature metadata files from signed APK. Yields (ZipInfo, data) pairs. + + >>> from apksigcopier import extract_meta + >>> apk = "test/apks/apks/golden-aligned-v1v2v3-out.apk" + >>> meta = tuple(extract_meta(apk)) + >>> [ x.filename for x, _ in meta ] + ['META-INF/RSA-2048.SF', 'META-INF/RSA-2048.RSA', 'META-INF/MANIFEST.MF'] + >>> for line in meta[0][1].splitlines()[:4]: + ... print(line.decode()) + Signature-Version: 1.0 + Created-By: 1.0 (Android) + SHA-256-Digest-Manifest: hz7AxDJU9Namxoou/kc4Z2GVRS9anCGI+M52tbCsXT0= + X-Android-APK-Signed: 2, 3 + >>> for line in meta[2][1].splitlines()[:2]: + ... print(line.decode()) + Manifest-Version: 1.0 + Created-By: 1.8.0_45-internal (Oracle Corporation) + """ with zipfile.ZipFile(signed_apk, "r") as zf_sig: for info in zf_sig.infolist(): @@ -410,34 +587,62 @@ def extract_meta(signed_apk: str) -> Iterator[Tuple[zipfile.ZipInfo, bytes]]: def extract_differences(signed_apk: str, extracted_meta: ZipInfoDataPairs) \ -> Optional[Dict[str, Any]]: - """Extract ZIP metadata differences from signed APK.""" + """ + Extract ZIP metadata differences from signed APK. + + >>> import apksigcopier as asc, pprint + >>> apk = "test/apks/apks/debuggable-boolean.apk" + >>> meta = tuple(asc.extract_meta(apk)) + >>> [ x.filename for x, _ in meta ] + ['META-INF/CERT.SF', 'META-INF/CERT.RSA', 'META-INF/MANIFEST.MF'] + >>> diff = asc.extract_differences(apk, meta) + >>> pprint.pprint(diff) + {'files': {'META-INF/CERT.RSA': {'flag_bits': 2056}, + 'META-INF/CERT.SF': {'flag_bits': 2056}, + 'META-INF/MANIFEST.MF': {'flag_bits': 2056}}} + + >>> meta[2][0].extract_version = 42 + >>> try: + ... asc.extract_differences(apk, meta) + ... except asc.ZipError as e: + ... print(e) + Unsupported extract_version + + >>> asc.validate_differences(diff) is None + True + >>> diff["files"]["META-INF/OOPS"] = {} + >>> asc.validate_differences(diff) + ".files key 'META-INF/OOPS' is not a metadata file" + >>> del diff["files"]["META-INF/OOPS"] + >>> diff["files"]["META-INF/CERT.RSA"]["compresslevel"] = 42 + >>> asc.validate_differences(diff) + ".files['META-INF/CERT.RSA'].compresslevel has an unexpected value" + >>> diff["oops"] = 42 + >>> asc.validate_differences(diff) + 'contains unknown key(s)' + + """ differences: Dict[str, Any] = {} files = {} for info, data in extracted_meta: diffs = {} - for k in VALID_ZIP_META.keys(): + for k in VALID_ZIP_META: if k != "compresslevel": v = getattr(info, k) if v != APKZipInfo._override[k]: if v not in VALID_ZIP_META[k]: raise ZipError(f"Unsupported {k}") diffs[k] = v - level = _get_compresslevel(info, data) + level = _get_compresslevel(signed_apk, info, data) if level != APKZipInfo.COMPRESSLEVEL: diffs["compresslevel"] = level if diffs: files[info.filename] = diffs if files: differences["files"] = files - with open(signed_apk, "rb") as fh: - zfe_start = zipflinger_virtual_entry(30)[:28] # w/o len(extra) - if fh.read(28) == zfe_start: - zfe_size = 30 + int.from_bytes(fh.read(2), "little") - if not (30 <= zfe_size <= 4096): - raise ZipError("Unsupported virtual entry size") - if not fh.read(zfe_size - 30) == b"\x00" * (zfe_size - 30): - raise ZipError("Unsupported virtual entry data") - differences["zipflinger_virtual_entry"] = zfe_size + zfe_size = detect_zfe(signed_apk) + if zfe_size: + differences["zipflinger_virtual_entry"] = zfe_size return differences or None @@ -447,7 +652,7 @@ def validate_differences(differences: Dict[str, Any]) -> Optional[str]: Returns None if valid, error otherwise. """ - if set(differences.keys()) - {"files", "zipflinger_virtual_entry"}: + if set(differences) - {"files", "zipflinger_virtual_entry"}: return "contains unknown key(s)" if "zipflinger_virtual_entry" in differences: if type(differences["zipflinger_virtual_entry"]) is not int: @@ -458,9 +663,11 @@ def validate_differences(differences: Dict[str, Any]) -> Optional[str]: if not isinstance(differences["files"], dict): return ".files is not a dict" for name, info in differences["files"].items(): + if not is_meta(name): + return f".files key {name!r} is not a metadata file" if not isinstance(info, dict): return f".files[{name!r}] is not a dict" - if set(info.keys()) - set(VALID_ZIP_META.keys()): + if set(info) - set(VALID_ZIP_META): return f".files[{name!r}] contains unknown key(s)" for k, v in info.items(): if v not in VALID_ZIP_META[k]: @@ -468,21 +675,72 @@ def validate_differences(differences: Dict[str, Any]) -> Optional[str]: return None -# FIXME: false positives on same compressed size? compare actual data? -def _get_compresslevel(info: zipfile.ZipInfo, data: bytes) -> int: +def _get_compresslevel(apkfile: str, info: zipfile.ZipInfo, data: bytes) -> int: if info.compress_type != 8: raise ZipError("Unsupported compress_type") + crc = _get_compressed_crc(apkfile, info) for level in VALID_ZIP_META["compresslevel"]: comp = zlib.compressobj(level, 8, -15) - if len(comp.compress(data) + comp.flush()) == info.compress_size: + if zlib.crc32(comp.compress(data) + comp.flush()) == crc: return level raise ZipError("Unsupported compresslevel") +def _get_compressed_crc(apkfile: str, info: zipfile.ZipInfo) -> int: + with open(apkfile, "rb") as fh: + fh.seek(info.header_offset) + hdr = fh.read(30) + if hdr[:4] != b"\x50\x4b\x03\x04": + raise ZipError("Expected local file header signature") + n, m = struct.unpack(" None: - """Add v1 signature metadata to APK (removes v2 sig block, if any).""" + """ + Add v1 signature metadata to APK (removes v2 sig block, if any). + + >>> import apksigcopier as asc + >>> unsigned_apk = "test/apks/apks/golden-aligned-in.apk" + >>> signed_apk = "test/apks/apks/golden-aligned-v1v2v3-out.apk" + >>> meta = tuple(asc.extract_meta(signed_apk)) + >>> [ x.filename for x, _ in meta ] + ['META-INF/RSA-2048.SF', 'META-INF/RSA-2048.RSA', 'META-INF/MANIFEST.MF'] + >>> with zipfile.ZipFile(unsigned_apk, "r") as zf: + ... infos_in = zf.infolist() + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... out = os.path.join(tmpdir, "out.apk") + ... asc.copy_apk(unsigned_apk, out) + ... asc.patch_meta(meta, out) + ... with zipfile.ZipFile(out, "r") as zf: + ... infos_out = zf.infolist() + (2017, 5, 15, 11, 28, 40) + >>> for i in infos_in: + ... print(i.filename) + META-INF/ + META-INF/MANIFEST.MF + AndroidManifest.xml + classes.dex + temp.txt + lib/armeabi/fake.so + resources.arsc + temp2.txt + >>> for i in infos_out: + ... print(i.filename) + AndroidManifest.xml + classes.dex + temp.txt + lib/armeabi/fake.so + resources.arsc + temp2.txt + META-INF/RSA-2048.SF + META-INF/RSA-2048.RSA + META-INF/MANIFEST.MF + + """ with zipfile.ZipFile(output_apk, "r") as zf_out: for info in zf_out.infolist(): if is_meta(info.filename): @@ -504,6 +762,22 @@ def extract_v2_sig(apkfile: str, expected: bool = True) -> Optional[Tuple[int, b When successful, returns (sb_offset, sig_block); otherwise raises NoAPKSigningBlock when expected is True, else returns None. + + >>> import apksigcopier as asc + >>> apk = "test/apks/apks/golden-aligned-v1v2v3-out.apk" + >>> sb_offset, sig_block = asc.extract_v2_sig(apk) + >>> sb_offset + 8192 + >>> len(sig_block) + 4096 + + >>> apk = "test/apks/apks/golden-aligned-in.apk" + >>> try: + ... asc.extract_v2_sig(apk) + ... except asc.NoAPKSigningBlock as e: + ... print(e) + No APK Signing Block + """ cd_offset = zip_data(apkfile).cd_offset with open(apkfile, "rb") as fh: @@ -529,9 +803,16 @@ def zip_data(apkfile: str, count: int = 1024) -> ZipData: """ Extract central directory, EOCD, and offsets from ZIP. - Returns - ------- - ZipData + Returns ZipData. + + >>> import apksigcopier + >>> apk = "test/apks/apks/golden-aligned-v1v2v3-out.apk" + >>> data = apksigcopier.zip_data(apk) + >>> data.cd_offset, data.eocd_offset + (12288, 12843) + >>> len(data.cd_and_eocd) + 577 + """ with open(apkfile, "rb") as fh: fh.seek(-count, os.SEEK_END) @@ -550,7 +831,28 @@ def zip_data(apkfile: str, count: int = 1024) -> ZipData: # FIXME: can we determine signed_sb_offset? def patch_v2_sig(extracted_v2_sig: Tuple[int, bytes], output_apk: str) -> None: - """Implant extracted v2/v3 signature into APK.""" + """ + Implant extracted v2/v3 signature into APK. + + >>> import apksigcopier as asc + >>> unsigned_apk = "test/apks/apks/golden-aligned-in.apk" + >>> signed_apk = "test/apks/apks/golden-aligned-v1v2v3-out.apk" + >>> meta = tuple(asc.extract_meta(signed_apk)) + >>> v2_sig = asc.extract_v2_sig(signed_apk) + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... out = os.path.join(tmpdir, "out.apk") + ... date_time = asc.copy_apk(unsigned_apk, out) + ... asc.patch_meta(meta, out, date_time=date_time) + ... asc.extract_v2_sig(out, expected=False) is None + ... asc.patch_v2_sig(v2_sig, out) + ... asc.extract_v2_sig(out) == v2_sig + ... with open(signed_apk, "rb") as a, open(out, "rb") as b: + ... a.read() == b.read() + True + True + True + + """ signed_sb_offset, signed_sb = extracted_v2_sig data_out = zip_data(output_apk) if signed_sb_offset < data_out.cd_offset: @@ -568,18 +870,37 @@ def patch_v2_sig(extracted_v2_sig: Tuple[int, bytes], output_apk: str) -> None: def patch_apk(extracted_meta: ZipInfoDataPairs, extracted_v2_sig: Optional[Tuple[int, bytes]], unsigned_apk: str, output_apk: str, *, - differences: Optional[Dict[str, Any]] = None) -> None: - """Patch extracted_meta + extracted_v2_sig (if not None) onto unsigned_apk and save as output_apk.""" + differences: Optional[Dict[str, Any]] = None, + exclude: Optional[Callable[[str], bool]] = None) -> None: + """ + Patch extracted_meta + extracted_v2_sig (if not None) onto unsigned_apk and + save as output_apk. + """ if differences and "zipflinger_virtual_entry" in differences: zfe_size = differences["zipflinger_virtual_entry"] else: zfe_size = None - date_time = copy_apk(unsigned_apk, output_apk, zfe_size=zfe_size) + date_time = copy_apk(unsigned_apk, output_apk, exclude=exclude, zfe_size=zfe_size) patch_meta(extracted_meta, output_apk, date_time=date_time, differences=differences) if extracted_v2_sig is not None: patch_v2_sig(extracted_v2_sig, output_apk) +def verify_apk(apk: str, min_sdk_version: Optional[int] = None, + verify_cmd: Optional[Tuple[str, ...]] = None) -> None: + """Verifies APK using apksigner.""" + args = tuple(verify_cmd or VERIFY_CMD) + if min_sdk_version is not None: + args += (f"--min-sdk-version={min_sdk_version}",) + args += ("--", apk) + try: + subprocess.run(args, check=True, stdout=subprocess.PIPE) + except subprocess.CalledProcessError: + raise APKSigCopierError(f"failed to verify {apk}") # pylint: disable=W0707 + except FileNotFoundError: + raise APKSigCopierError(f"{args[0]} command not found") # pylint: disable=W0707 + + # FIXME: support multiple signers? def do_extract(signed_apk: str, output_dir: str, v1_only: NoAutoYesBoolNone = NO, *, ignore_differences: bool = False) -> None: @@ -625,7 +946,9 @@ def do_extract(signed_apk: str, output_dir: str, v1_only: NoAutoYesBoolNone = NO # FIXME: support multiple signers? def do_patch(metadata_dir: str, unsigned_apk: str, output_apk: str, - v1_only: NoAutoYesBoolNone = NO, *, ignore_differences: bool = False) -> None: + v1_only: NoAutoYesBoolNone = NO, *, + exclude: Optional[Callable[[str], bool]] = None, + ignore_differences: bool = False) -> None: """ Patch signatures from metadata_dir onto unsigned_apk and save as output_apk. @@ -674,11 +997,13 @@ def do_patch(metadata_dir: str, unsigned_apk: str, output_apk: str, if not extracted_meta and extracted_v2_sig is None: raise APKSigCopierError("Expected v1 and/or v2/v3 signature, found neither") patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk, - differences=differences) + differences=differences, exclude=exclude) def do_copy(signed_apk: str, unsigned_apk: str, output_apk: str, - v1_only: NoAutoYesBoolNone = NO, *, ignore_differences: bool = False) -> None: + v1_only: NoAutoYesBoolNone = NO, *, + exclude: Optional[Callable[[str], bool]] = None, + ignore_differences: bool = False) -> None: """ Copy signatures from signed_apk onto unsigned_apk and save as output_apk. @@ -698,6 +1023,111 @@ def do_copy(signed_apk: str, unsigned_apk: str, output_apk: str, if extracted_v2_sig is not None and not ignore_differences: differences = extract_differences(signed_apk, extracted_meta) patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk, - differences=differences) + differences=differences, exclude=exclude) + + +def do_compare(first_apk: str, second_apk: str, unsigned: bool = False, + min_sdk_version: Optional[int] = None, *, + ignore_differences: bool = False, + verify_cmd: Optional[Tuple[str, ...]] = None) -> None: + """ + Compare first_apk to second_apk by: + * using apksigner to check if the first APK verifies + * checking if the second APK also verifies (unless unsigned is True) + * copying the signature from first_apk to a copy of second_apk + * checking if the resulting APK verifies + """ + verify_apk(first_apk, min_sdk_version=min_sdk_version, verify_cmd=verify_cmd) + if not unsigned: + verify_apk(second_apk, min_sdk_version=min_sdk_version, verify_cmd=verify_cmd) + with tempfile.TemporaryDirectory() as tmpdir: + output_apk = os.path.join(tmpdir, "output.apk") # FIXME + exclude = exclude_default if unsigned else exclude_meta + do_copy(first_apk, second_apk, output_apk, AUTO, exclude=exclude, + ignore_differences=ignore_differences) + verify_apk(output_apk, min_sdk_version=min_sdk_version, verify_cmd=verify_cmd) + + +def main() -> None: + """CLI; requires click.""" + + global exclude_all_meta, copy_extra_bytes, skip_realignment + exclude_all_meta = os.environ.get("APKSIGCOPIER_EXCLUDE_ALL_META") in ("1", "yes", "true") + copy_extra_bytes = os.environ.get("APKSIGCOPIER_COPY_EXTRA_BYTES") in ("1", "yes", "true") + skip_realignment = os.environ.get("APKSIGCOPIER_SKIP_REALIGNMENT") in ("1", "yes", "true") + + import click + + NAY = click.Choice(NOAUTOYES) + + @click.group(help=""" + apksigcopier - copy/extract/patch android apk signatures & compare apks + """) + @click.version_option(__version__) + def cli() -> None: + pass + + @cli.command(help=""" + Extract APK signatures from signed APK. + """) + @click.option("--v1-only", type=NAY, default=NO, show_default=True, + envvar="APKSIGCOPIER_V1_ONLY", help="Expect only a v1 signature.") + @click.option("--ignore-differences", is_flag=True, help="Don't write differences.json.") + @click.argument("signed_apk", type=click.Path(exists=True, dir_okay=False)) + @click.argument("output_dir", type=click.Path(exists=True, file_okay=False)) + def extract(*args: Any, **kwargs: Any) -> None: + do_extract(*args, **kwargs) + + @cli.command(help=""" + Patch extracted APK signatures onto unsigned APK. + """) + @click.option("--v1-only", type=NAY, default=NO, show_default=True, + envvar="APKSIGCOPIER_V1_ONLY", help="Expect only a v1 signature.") + @click.option("--ignore-differences", is_flag=True, help="Don't read differences.json.") + @click.argument("metadata_dir", type=click.Path(exists=True, file_okay=False)) + @click.argument("unsigned_apk", type=click.Path(exists=True, dir_okay=False)) + @click.argument("output_apk", type=click.Path(dir_okay=False)) + def patch(*args: Any, **kwargs: Any) -> None: + do_patch(*args, **kwargs) + + @cli.command(help=""" + Copy (extract & patch) signatures from signed to unsigned APK. + """) + @click.option("--v1-only", type=NAY, default=NO, show_default=True, + envvar="APKSIGCOPIER_V1_ONLY", help="Expect only a v1 signature.") + @click.option("--ignore-differences", is_flag=True, help="Don't copy metadata differences.") + @click.argument("signed_apk", type=click.Path(exists=True, dir_okay=False)) + @click.argument("unsigned_apk", type=click.Path(exists=True, dir_okay=False)) + @click.argument("output_apk", type=click.Path(dir_okay=False)) + def copy(*args: Any, **kwargs: Any) -> None: + do_copy(*args, **kwargs) + + @cli.command(help=""" + Compare two APKs by copying the signature from the first to a copy of + the second and checking if the resulting APK verifies. + + This command requires apksigner. + """) + @click.option("--unsigned", is_flag=True, help="Accept unsigned SECOND_APK.") + @click.option("--min-sdk-version", type=click.INT, help="Passed to apksigner.") + @click.option("--ignore-differences", is_flag=True, help="Don't copy metadata differences.") + @click.option("--verify-cmd", metavar="COMMAND", help="Command (with arguments) used to " + f"verify APKs. [default: {' '.join(VERIFY_CMD)!r}]") + @click.argument("first_apk", type=click.Path(exists=True, dir_okay=False)) + @click.argument("second_apk", type=click.Path(exists=True, dir_okay=False)) + def compare(*args: Any, **kwargs: Any) -> None: + if kwargs["verify_cmd"] is not None: + kwargs["verify_cmd"] = tuple(kwargs["verify_cmd"].split()) + do_compare(*args, **kwargs) + + try: + cli(prog_name=NAME) + except (APKSigCopierError, zipfile.BadZipFile) as e: + click.echo(f"Error: {e}.", err=True) + sys.exit(1) + + +if __name__ == "__main__": + main() # vim: set tw=80 sw=4 sts=4 et fdm=marker :