mirror of
https://gitlab.com/fdroid/fdroidserver.git
synced 2024-11-18 20:50:10 +01:00
import apksigcopier v1.1.1
This commit is contained in:
parent
4de2e3cf04
commit
c68e1489bd
@ -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,25 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
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,7 +92,9 @@ 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)
|
||||||
|
VERIFY_CMD: Tuple[str, ...] = ("apksigner", "verify")
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
#
|
#
|
||||||
@ -107,7 +126,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 +153,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]
|
||||||
@ -167,7 +189,7 @@ class APKZipInfo(ReproducibleZipInfo):
|
|||||||
|
|
||||||
def noautoyes(value: NoAutoYesBoolNone) -> NoAutoYes:
|
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
|
>>> from apksigcopier import noautoyes, NO, AUTO, YES
|
||||||
>>> noautoyes(False) == NO == noautoyes(NO)
|
>>> noautoyes(False) == NO == noautoyes(NO)
|
||||||
@ -190,24 +212,84 @@ def noautoyes(value: NoAutoYesBoolNone) -> NoAutoYes:
|
|||||||
|
|
||||||
def is_meta(filename: str) -> bool:
|
def is_meta(filename: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check whether filename is a JAR metadata file.
|
Returns whether filename is a v1 (JAR) signature file (.SF), signature block
|
||||||
|
file (.RSA, .DSA, or .EC), or manifest (MANIFEST.MF).
|
||||||
This is true when 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
|
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
|
||||||
|
|
||||||
|
|
||||||
def exclude_from_copying(filename: str) -> bool:
|
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)
|
) + 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:
|
||||||
|
if pad_like_apksigner:
|
||||||
pad = (align - (new_off - m + len(new_xtr) + 6) % align) % align
|
pad = (align - (new_off - m + len(new_xtr) + 6) % align) % align
|
||||||
xtr = new_xtr + struct.pack("<HHH", 0xd935, 2 + pad, align) + pad * b"\x00"
|
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,33 +587,61 @@ 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:
|
|
||||||
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
|
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,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]],
|
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,
|
||||||
"""Patch extracted_meta + extracted_v2_sig (if not None) onto unsigned_apk and save as output_apk."""
|
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:
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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?
|
# FIXME: support multiple signers?
|
||||||
def do_extract(signed_apk: str, output_dir: str, v1_only: NoAutoYesBoolNone = NO,
|
def do_extract(signed_apk: str, output_dir: str, v1_only: NoAutoYesBoolNone = NO,
|
||||||
*, ignore_differences: bool = False) -> None:
|
*, 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?
|
# 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 +997,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 +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:
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
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 :
|
# vim: set tw=80 sw=4 sts=4 et fdm=marker :
|
||||||
|
Loading…
Reference in New Issue
Block a user