Merge branch 'apksigcopier-1.1.1' into 'master'

update apksigcopier to upstream v1.1.1

See merge request fdroid/fdroidserver!1302
This commit is contained in:
Hans-Christoph Steiner 2023-02-17 13:20:40 +00:00
commit 6e2b4f65c2
2 changed files with 369 additions and 60 deletions

View file

@ -1,26 +1,29 @@
#!/usr/bin/python3 #!/usr/bin/python3
# encoding: utf-8 # encoding: utf-8
# SPDX-FileCopyrightText: 2022 FC Stegerman <flx@obfusk.net> # SPDX-FileCopyrightText: 2023 FC Stegerman <flx@obfusk.net>
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
# -- ; {{{1 # -- ; {{{1
# #
# File : apksigcopier # File : apksigcopier
# Maintainer : FC Stegerman <flx@obfusk.net> # Maintainer : FC Stegerman <flx@obfusk.net>
# Date : 2022-11-01 # Date : 2023-02-08
# #
# Copyright : Copyright (C) 2022 FC Stegerman # Copyright : Copyright (C) 2023 FC Stegerman
# Version : v1.1.0 # Version : v1.1.1
# License : GPLv3+ # License : GPLv3+
# #
# -- ; }}}1 # -- ; }}}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 apksigcopier is a tool for copying android APK signatures from a signed APK to
an unsigned one (in order to verify reproducible builds). 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 CLI
=== ===
@ -28,27 +31,33 @@ CLI
$ apksigcopier extract [OPTIONS] SIGNED_APK OUTPUT_DIR $ apksigcopier extract [OPTIONS] SIGNED_APK OUTPUT_DIR
$ apksigcopier patch [OPTIONS] METADATA_DIR UNSIGNED_APK OUTPUT_APK $ apksigcopier patch [OPTIONS] METADATA_DIR UNSIGNED_APK OUTPUT_APK
$ apksigcopier copy [OPTIONS] SIGNED_APK 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 The following environment variables can be set to 1, yes, or true to
override the default behaviour: 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_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 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_extract(signed_apk, output_dir, v1_only=NO)
>> do_patch(metadata_dir, unsigned_apk, output_apk, 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_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. 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 The following global variables (which default to False), can be set to
override the default behaviour: 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 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 import glob
@ -56,17 +65,23 @@ import json
import os import os
import re import re
import struct import struct
import sys
import zipfile import zipfile
import zlib import zlib
from collections import namedtuple 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" 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] DateTime = Tuple[int, int, int, int, int, int]
NoAutoYes = Literal["no", "auto", "yes"]
NoAutoYesBoolNone = Union[NoAutoYes, bool, None] NoAutoYesBoolNone = Union[NoAutoYes, bool, None]
ZipInfoDataPairs = Iterable[Tuple[zipfile.ZipInfo, bytes]] ZipInfoDataPairs = Iterable[Tuple[zipfile.ZipInfo, bytes]]
@ -75,6 +90,7 @@ NOAUTOYES: Tuple[NoAutoYes, NoAutoYes, NoAutoYes] = ("no", "auto", "yes")
NO, AUTO, YES = NOAUTOYES NO, AUTO, YES = NOAUTOYES
APK_META = re.compile(r"^META-INF/([0-9A-Za-z_-]+\.(SF|RSA|DSA|EC)|MANIFEST\.MF)$") 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") 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) DATETIMEZERO: DateTime = (1980, 0, 0, 0, 0, 0)
################################################################################ ################################################################################
@ -107,7 +123,9 @@ VALID_ZIP_META = dict(
ZipData = namedtuple("ZipData", ("cd_offset", "eocd_offset", "cd_and_eocd")) 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() 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): class APKSigCopierError(Exception):
@ -132,14 +150,15 @@ class ReproducibleZipInfo(zipfile.ZipInfo):
_override: Dict[str, Any] = {} _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: if override:
self._override = {**self._override, **override} self._override = {**self._override, **override}
for k in self.__slots__: for k in self.__slots__:
if hasattr(zinfo, k): if hasattr(zinfo, k):
setattr(self, k, getattr(zinfo, k)) setattr(self, k, getattr(zinfo, k))
def __getattribute__(self, name): def __getattribute__(self, name: str) -> Any:
if name != "_override": if name != "_override":
try: try:
return self._override[name] return self._override[name]
@ -192,10 +211,23 @@ def is_meta(filename: str) -> bool:
""" """
Check whether filename is a JAR metadata file. Check whether filename is a JAR metadata file.
This is true when filename is a v1 (JAR) signature file (.SF), signature Returns whether filename is a v1 (JAR) signature file (.SF), signature block
block file (.RSA, .DSA, or .EC), or manifest (MANIFEST.MF). file (.RSA, .DSA, or .EC), or manifest (MANIFEST.MF).
See https://docs.oracle.com/javase/tutorial/deployment/jar/intro.html 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 return APK_META.fullmatch(filename) is not None
@ -204,10 +236,60 @@ def exclude_from_copying(filename: str) -> bool:
""" """
Check whether to exclude a file during copy_apk(). Check whether to exclude a file during copy_apk().
Fdroidserver always wants JAR Signature files to be excluded, so Excludes filenames in COPY_EXCLUDE (i.e. MANIFEST.MF) by default; when
it excludes all metadata files as matched by is_meta(). 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
""" """
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) ) + 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) # 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)? # FIXME: handle utf8 filenames w/o utf8 flag (as produced by zipflinger)?
# https://android.googlesource.com/platform/tools/apksig # https://android.googlesource.com/platform/tools/apksig
# src/main/java/com/android/apksig/ApkSigner.java # 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(). 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 Adds a zipflinger virtual entry of zfe_size bytes if one is not already
present and zfe_size is not None. present and zfe_size is not None.
Returns max date_time.
The following global variables (which default to False), can be set to The following global variables (which default to False), can be set to
override the default behaviour: 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 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]
<ZipInfo filename='AndroidManifest.xml' compress_type=deflate file_size=1672 compress_size=630>
>>> infos_out[0]
<ZipInfo filename='AndroidManifest.xml' compress_type=deflate file_size=1672 compress_size=630>
>>> 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: with zipfile.ZipFile(unsigned_apk, "r") as zf:
infos = zf.infolist() infos = zf.infolist()
zdata = zip_data(unsigned_apk) 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") raise ZipError("Expected local file header signature")
n, m = struct.unpack("<HH", hdr[26:30]) n, m = struct.unpack("<HH", hdr[26:30])
hdr += fhi.read(n + m) hdr += fhi.read(n + m)
skip = exclude_from_copying(info.filename) skip = exclude(info.filename)
if skip: if skip:
fhi.seek(info.compress_size, os.SEEK_CUR) fhi.seek(info.compress_size, os.SEEK_CUR)
else: else:
if info.filename in offsets: if info.filename in offsets:
raise ZipError(f"Duplicate ZIP entry: {info.filename!r}") raise ZipError(f"Duplicate ZIP entry: {info.filename!r}")
offsets[info.filename] = off_o = fho.tell() offsets[info.filename] = off_o = fho.tell()
if info.compress_type == 0 and off_o != info.header_offset: if realign and info.compress_type == 0 and off_o != info.header_offset:
hdr = _realign_zip_entry(info, hdr, n, m, off_o) hdr = _realign_zip_entry(info, hdr, n, m, off_o,
pad_like_apksigner=not zfe_size)
fho.write(hdr) fho.write(hdr)
_copy_bytes(fhi, fho, info.compress_size) _copy_bytes(fhi, fho, info.compress_size)
if info.flag_bits & 0x08: if info.flag_bits & 0x08:
@ -336,7 +491,7 @@ def copy_apk(unsigned_apk: str, output_apk: str, *, zfe_size: Optional[int] = No
if not skip: if not skip:
fho.write(data_descriptor) fho.write(data_descriptor)
extra_bytes = zdata.cd_offset - fhi.tell() extra_bytes = zdata.cd_offset - fhi.tell()
if copy_extra_bytes: if copy_extra:
_copy_bytes(fhi, fho, extra_bytes) _copy_bytes(fhi, fho, extra_bytes)
else: else:
fhi.seek(extra_bytes, os.SEEK_CUR) fhi.seek(extra_bytes, os.SEEK_CUR)
@ -347,7 +502,7 @@ def copy_apk(unsigned_apk: str, output_apk: str, *, zfe_size: Optional[int] = No
raise ZipError("Expected central directory file header signature") raise ZipError("Expected central directory file header signature")
n, m, k = struct.unpack("<HHH", hdr[28:34]) n, m, k = struct.unpack("<HHH", hdr[28:34])
hdr += fhi.read(n + m + k) hdr += fhi.read(n + m + k)
if not exclude_from_copying(info.filename): if not exclude(info.filename):
off = int.to_bytes(offsets[info.filename], 4, "little") off = int.to_bytes(offsets[info.filename], 4, "little")
hdr = hdr[:42] + off + hdr[46:] hdr = hdr[:42] + off + hdr[46:]
fho.write(hdr) fho.write(hdr)
@ -356,15 +511,16 @@ def copy_apk(unsigned_apk: str, output_apk: str, *, zfe_size: Optional[int] = No
fho.seek(eocd_offset + 8) fho.seek(eocd_offset + 8)
fho.write(struct.pack("<HHLL", len(offsets), len(offsets), fho.write(struct.pack("<HHLL", len(offsets), len(offsets),
eocd_offset - cd_offset, cd_offset)) eocd_offset - cd_offset, cd_offset))
return max(info.date_time for info in infos) return max(info.date_time for info in infos if info.filename in offsets)
# NB: doesn't sync local & CD headers! # NB: doesn't sync local & CD headers!
def _realign_zip_entry(info: zipfile.ZipInfo, hdr: bytes, n: int, m: int, off_o: int) -> 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 align = 4096 if info.filename.endswith(".so") else 4
old_off = 30 + n + m + info.header_offset old_off = 30 + n + m + info.header_offset
new_off = 30 + n + m + off_o new_off = 30 + n + m + off_o
old_xtr = info.extra old_xtr = hdr[30 + n:30 + n + m]
new_xtr = b"" new_xtr = b""
while len(old_xtr) >= 4: while len(old_xtr) >= 4:
hdr_id, size = struct.unpack("<HH", old_xtr[:4]) hdr_id, size = struct.unpack("<HH", old_xtr[:4])
@ -378,8 +534,12 @@ def _realign_zip_entry(info: zipfile.ZipInfo, hdr: bytes, n: int, m: int, off_o:
new_xtr += old_xtr[:size + 4] new_xtr += old_xtr[:size + 4]
old_xtr = old_xtr[size + 4:] old_xtr = old_xtr[size + 4:]
if old_off % align == 0 and new_off % align != 0: if old_off % align == 0 and new_off % align != 0:
pad = (align - (new_off - m + len(new_xtr) + 6) % align) % align if pad_like_apksigner:
xtr = new_xtr + struct.pack("<HHH", 0xd935, 2 + pad, align) + pad * b"\x00" pad = (align - (new_off - m + len(new_xtr) + 6) % align) % align
xtr = new_xtr + struct.pack("<HHH", 0xd935, 2 + pad, align) + pad * b"\x00"
else:
pad = (align - (new_off - m + len(new_xtr)) % align) % align
xtr = new_xtr + pad * b"\x00"
m_b = int.to_bytes(len(xtr), 2, "little") m_b = int.to_bytes(len(xtr), 2, "little")
hdr = hdr[:28] + m_b + hdr[30:30 + n] + xtr hdr = hdr[:28] + m_b + hdr[30:30 + n] + xtr
return hdr return hdr
@ -401,6 +561,23 @@ def extract_meta(signed_apk: str) -> Iterator[Tuple[zipfile.ZipInfo, bytes]]:
Extract v1 signature metadata files from signed APK. Extract v1 signature metadata files from signed APK.
Yields (ZipInfo, data) pairs. 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: with zipfile.ZipFile(signed_apk, "r") as zf_sig:
for info in zf_sig.infolist(): 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) \ def extract_differences(signed_apk: str, extracted_meta: ZipInfoDataPairs) \
-> Optional[Dict[str, Any]]: -> 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] = {} differences: Dict[str, Any] = {}
files = {} files = {}
for info, data in extracted_meta: for info, data in extracted_meta:
diffs = {} diffs = {}
for k in VALID_ZIP_META.keys(): for k in VALID_ZIP_META:
if k != "compresslevel": if k != "compresslevel":
v = getattr(info, k) v = getattr(info, k)
if v != APKZipInfo._override[k]: if v != APKZipInfo._override[k]:
if v not in VALID_ZIP_META[k]: if v not in VALID_ZIP_META[k]:
raise ZipError(f"Unsupported {k}") raise ZipError(f"Unsupported {k}")
diffs[k] = v diffs[k] = v
level = _get_compresslevel(info, data) level = _get_compresslevel(signed_apk, info, data)
if level != APKZipInfo.COMPRESSLEVEL: if level != APKZipInfo.COMPRESSLEVEL:
diffs["compresslevel"] = level diffs["compresslevel"] = level
if diffs: if diffs:
files[info.filename] = diffs files[info.filename] = diffs
if files: if files:
differences["files"] = files differences["files"] = files
with open(signed_apk, "rb") as fh: zfe_size = detect_zfe(signed_apk)
zfe_start = zipflinger_virtual_entry(30)[:28] # w/o len(extra) if zfe_size:
if fh.read(28) == zfe_start: differences["zipflinger_virtual_entry"] = zfe_size
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
return differences or None return differences or None
@ -447,7 +652,7 @@ def validate_differences(differences: Dict[str, Any]) -> Optional[str]:
Returns None if valid, error otherwise. 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)" return "contains unknown key(s)"
if "zipflinger_virtual_entry" in differences: if "zipflinger_virtual_entry" in differences:
if type(differences["zipflinger_virtual_entry"]) is not int: 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): if not isinstance(differences["files"], dict):
return ".files is not a dict" return ".files is not a dict"
for name, info in differences["files"].items(): 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): if not isinstance(info, dict):
return f".files[{name!r}] is not a 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)" return f".files[{name!r}] contains unknown key(s)"
for k, v in info.items(): for k, v in info.items():
if v not in VALID_ZIP_META[k]: if v not in VALID_ZIP_META[k]:
@ -468,21 +675,72 @@ def validate_differences(differences: Dict[str, Any]) -> Optional[str]:
return None return None
# FIXME: false positives on same compressed size? compare actual data? def _get_compresslevel(apkfile: str, info: zipfile.ZipInfo, data: bytes) -> int:
def _get_compresslevel(info: zipfile.ZipInfo, data: bytes) -> int:
if info.compress_type != 8: if info.compress_type != 8:
raise ZipError("Unsupported compress_type") raise ZipError("Unsupported compress_type")
crc = _get_compressed_crc(apkfile, info)
for level in VALID_ZIP_META["compresslevel"]: for level in VALID_ZIP_META["compresslevel"]:
comp = zlib.compressobj(level, 8, -15) 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 return level
raise ZipError("Unsupported compresslevel") 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("<HH", hdr[26:30])
fh.seek(n + m, os.SEEK_CUR)
return zlib.crc32(fh.read(info.compress_size))
def patch_meta(extracted_meta: ZipInfoDataPairs, output_apk: str, def patch_meta(extracted_meta: ZipInfoDataPairs, output_apk: str,
date_time: DateTime = DATETIMEZERO, *, date_time: DateTime = DATETIMEZERO, *,
differences: Optional[Dict[str, Any]] = None) -> None: differences: Optional[Dict[str, Any]] = None) -> 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: with zipfile.ZipFile(output_apk, "r") as zf_out:
for info in zf_out.infolist(): for info in zf_out.infolist():
if is_meta(info.filename): 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 When successful, returns (sb_offset, sig_block); otherwise raises
NoAPKSigningBlock when expected is True, else returns None. 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 cd_offset = zip_data(apkfile).cd_offset
with open(apkfile, "rb") as fh: 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. Extract central directory, EOCD, and offsets from ZIP.
Returns Returns ZipData.
-------
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: with open(apkfile, "rb") as fh:
fh.seek(-count, os.SEEK_END) 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? # FIXME: can we determine signed_sb_offset?
def patch_v2_sig(extracted_v2_sig: Tuple[int, bytes], output_apk: str) -> None: 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 signed_sb_offset, signed_sb = extracted_v2_sig
data_out = zip_data(output_apk) data_out = zip_data(output_apk)
if signed_sb_offset < data_out.cd_offset: if signed_sb_offset < data_out.cd_offset:
@ -568,13 +870,14 @@ 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]], def patch_apk(extracted_meta: ZipInfoDataPairs, extracted_v2_sig: Optional[Tuple[int, bytes]],
unsigned_apk: str, output_apk: str, *, unsigned_apk: str, output_apk: str, *,
differences: Optional[Dict[str, Any]] = None) -> None: 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.""" """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: if differences and "zipflinger_virtual_entry" in differences:
zfe_size = differences["zipflinger_virtual_entry"] zfe_size = differences["zipflinger_virtual_entry"]
else: else:
zfe_size = None 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) patch_meta(extracted_meta, output_apk, date_time=date_time, differences=differences)
if extracted_v2_sig is not None: if extracted_v2_sig is not None:
patch_v2_sig(extracted_v2_sig, output_apk) patch_v2_sig(extracted_v2_sig, output_apk)
@ -625,7 +928,9 @@ def do_extract(signed_apk: str, output_dir: str, v1_only: NoAutoYesBoolNone = NO
# FIXME: support multiple signers? # FIXME: support multiple signers?
def do_patch(metadata_dir: str, unsigned_apk: str, output_apk: str, 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. Patch signatures from metadata_dir onto unsigned_apk and save as output_apk.
@ -674,11 +979,13 @@ def do_patch(metadata_dir: str, unsigned_apk: str, output_apk: str,
if not extracted_meta and extracted_v2_sig is None: if not extracted_meta and extracted_v2_sig is None:
raise APKSigCopierError("Expected v1 and/or v2/v3 signature, found neither") raise APKSigCopierError("Expected v1 and/or v2/v3 signature, found neither")
patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk, 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, 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. Copy signatures from signed_apk onto unsigned_apk and save as output_apk.
@ -698,6 +1005,6 @@ def do_copy(signed_apk: str, unsigned_apk: str, output_apk: str,
if extracted_v2_sig is not None and not ignore_differences: if extracted_v2_sig is not None and not ignore_differences:
differences = extract_differences(signed_apk, extracted_meta) differences = extract_differences(signed_apk, extracted_meta)
patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk, patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk,
differences=differences) differences=differences, exclude=exclude)
# vim: set tw=80 sw=4 sts=4 et fdm=marker : # vim: set tw=80 sw=4 sts=4 et fdm=marker :

View file

@ -3258,7 +3258,8 @@ def apk_implant_signatures(apkpath, outpath, manifest):
""" """
sigdir = os.path.dirname(manifest) # FIXME sigdir = os.path.dirname(manifest) # FIXME
apksigcopier.do_patch(sigdir, apkpath, outpath, v1_only=None) apksigcopier.do_patch(sigdir, apkpath, outpath, v1_only=None,
exclude=apksigcopier.exclude_meta)
def apk_extract_signatures(apkpath, outdir): def apk_extract_signatures(apkpath, outdir):
@ -3388,7 +3389,8 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir, v1_only=None):
tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk)) tmp_apk = os.path.join(tmp_dir, 'sigcp_' + os.path.basename(unsigned_apk))
try: try:
apksigcopier.do_copy(signed_apk, unsigned_apk, tmp_apk, v1_only=v1_only) apksigcopier.do_copy(signed_apk, unsigned_apk, tmp_apk, v1_only=v1_only,
exclude=apksigcopier.exclude_meta)
except apksigcopier.APKSigCopierError as e: except apksigcopier.APKSigCopierError as e:
logging.info('...NOT verified - {0}'.format(tmp_apk)) logging.info('...NOT verified - {0}'.format(tmp_apk))
error = 'signature copying failed: {}'.format(str(e)) error = 'signature copying failed: {}'.format(str(e))