mirror of
https://github.com/f-droid/fdroidserver.git
synced 2025-09-15 15:32:30 +03:00
Merge branch 'apksigcopier' into 'master'
* origin/master: gitlab-ci: prevent dualing linters: pyflakes vs mypy jenkins-build-all: refocus on building reproducible apps first publish: rename vars to match naming in JAR Signature docs common.py: update copyrights apksigcopier: remove exclude_all_meta logic apksigcopier: purge main() to avoid confusion force apksigcopier "AUTO" to select sigs by available files publish: add test for reproduble builds with signatures vendor & use apksigcopier v0.4.0-12-g93d8e14 use subclass hack for better ZIP cloning fdroid/fdroidserver!893
This commit is contained in:
commit
c43581eb82
7 changed files with 763 additions and 100 deletions
|
@ -193,7 +193,8 @@ lint_mypy:
|
||||||
script:
|
script:
|
||||||
- pip install mypy
|
- pip install mypy
|
||||||
- pip install -e .[test]
|
- pip install -e .[test]
|
||||||
- mypy
|
# exclude vendored file
|
||||||
|
- mypy --exclude fdroidserver/apksigcopier.py
|
||||||
|
|
||||||
fedora_latest:
|
fedora_latest:
|
||||||
image: fedora:latest
|
image: fedora:latest
|
||||||
|
|
514
fdroidserver/apksigcopier.py
Normal file
514
fdroidserver/apksigcopier.py
Normal file
|
@ -0,0 +1,514 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
# encoding: utf-8
|
||||||
|
|
||||||
|
# -- ; {{{1
|
||||||
|
#
|
||||||
|
# File : apksigcopier
|
||||||
|
# Maintainer : Felix C. Stegerman <flx@obfusk.net>
|
||||||
|
# Date : 2021-04-14
|
||||||
|
#
|
||||||
|
# Copyright : Copyright (C) 2021 Felix C. Stegerman
|
||||||
|
# Version : v0.4.0
|
||||||
|
# License : GPLv3+
|
||||||
|
#
|
||||||
|
# -- ; }}}1
|
||||||
|
|
||||||
|
"""
|
||||||
|
copy/extract/patch apk signatures
|
||||||
|
|
||||||
|
apksigcopier is a tool for copying APK signatures from a signed APK to an
|
||||||
|
unsigned one (in order to verify reproducible builds).
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
The following environment variables can be set to 1, yes, or true to
|
||||||
|
overide 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)
|
||||||
|
|
||||||
|
|
||||||
|
API
|
||||||
|
===
|
||||||
|
|
||||||
|
>> from apksigcopier import do_extract, do_patch, do_copy
|
||||||
|
>> 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)
|
||||||
|
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
__version__ = "0.4.0"
|
||||||
|
NAME = "apksigcopier"
|
||||||
|
|
||||||
|
SIGBLOCK, SIGOFFSET = "APKSigningBlock", "APKSigningBlockOffset"
|
||||||
|
NOAUTOYES = NO, AUTO, YES = ("no", "auto", "yes")
|
||||||
|
APK_META = re.compile(r"^META-INF/([0-9A-Za-z_-]+\.(SF|RSA|DSA|EC)|MANIFEST\.MF)$")
|
||||||
|
META_EXT = ("SF", "RSA|DSA|EC", "MF")
|
||||||
|
COPY_EXCLUDE = ("META-INF/MANIFEST.MF",)
|
||||||
|
DATETIMEZERO = (1980, 0, 0, 0, 0, 0)
|
||||||
|
|
||||||
|
ZipData = namedtuple("ZipData", ("cd_offset", "eocd_offset", "cd_and_eocd"))
|
||||||
|
|
||||||
|
copy_extra_bytes = False # copy extra bytes after data in copy_apk()
|
||||||
|
|
||||||
|
|
||||||
|
class APKSigCopierError(Exception):
|
||||||
|
"""Base class for errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class APKSigningBlockError(APKSigCopierError):
|
||||||
|
"""Something wrong with the APK Signing Block."""
|
||||||
|
|
||||||
|
|
||||||
|
class NoAPKSigningBlock(APKSigningBlockError):
|
||||||
|
"""APK Signing Block Missing."""
|
||||||
|
|
||||||
|
|
||||||
|
class ZipError(APKSigCopierError):
|
||||||
|
"""Something wrong with ZIP file."""
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: is there a better alternative?
|
||||||
|
class ReproducibleZipInfo(zipfile.ZipInfo):
|
||||||
|
"""Reproducible ZipInfo hack."""
|
||||||
|
|
||||||
|
_override = {}
|
||||||
|
|
||||||
|
def __init__(self, zinfo, **override):
|
||||||
|
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):
|
||||||
|
if name != "_override":
|
||||||
|
try:
|
||||||
|
return self._override[name]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
return object.__getattribute__(self, name)
|
||||||
|
|
||||||
|
|
||||||
|
class APKZipInfo(ReproducibleZipInfo):
|
||||||
|
"""Reproducible ZipInfo for APK files."""
|
||||||
|
|
||||||
|
_override = dict(
|
||||||
|
compress_type=8,
|
||||||
|
create_system=0,
|
||||||
|
create_version=20,
|
||||||
|
date_time=DATETIMEZERO,
|
||||||
|
external_attr=0,
|
||||||
|
extract_version=20,
|
||||||
|
flag_bits=0x800,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def noautoyes(value):
|
||||||
|
"""
|
||||||
|
Turns False into NO, None into AUTO, and True into YES.
|
||||||
|
|
||||||
|
>>> from apksigcopier import noautoyes, NO, AUTO, YES
|
||||||
|
>>> noautoyes(False) == NO == noautoyes(NO)
|
||||||
|
True
|
||||||
|
>>> noautoyes(None) == AUTO == noautoyes(AUTO)
|
||||||
|
True
|
||||||
|
>>> noautoyes(True) == YES == noautoyes(YES)
|
||||||
|
True
|
||||||
|
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
if value not in NOAUTOYES:
|
||||||
|
raise ValueError("expected NO, AUTO, or YES")
|
||||||
|
return value
|
||||||
|
try:
|
||||||
|
return {False: NO, None: AUTO, True: YES}[value]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("expected False, None, or True")
|
||||||
|
|
||||||
|
|
||||||
|
def is_meta(filename):
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
return APK_META.fullmatch(filename) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def exclude_from_copying(filename):
|
||||||
|
"""fdroidserver always wants JAR Signature files to be excluded"""
|
||||||
|
return is_meta(filename)
|
||||||
|
|
||||||
|
|
||||||
|
################################################################################
|
||||||
|
#
|
||||||
|
# https://en.wikipedia.org/wiki/ZIP_(file_format)
|
||||||
|
# https://source.android.com/security/apksigning/v2#apk-signing-block-format
|
||||||
|
#
|
||||||
|
# =================================
|
||||||
|
# | Contents of ZIP entries |
|
||||||
|
# =================================
|
||||||
|
# | APK Signing Block |
|
||||||
|
# | ----------------------------- |
|
||||||
|
# | | size (w/o this) uint64 LE | |
|
||||||
|
# | | ... | |
|
||||||
|
# | | size (again) uint64 LE | |
|
||||||
|
# | | "APK Sig Block 42" (16B) | |
|
||||||
|
# | ----------------------------- |
|
||||||
|
# =================================
|
||||||
|
# | ZIP Central Directory |
|
||||||
|
# =================================
|
||||||
|
# | ZIP End of Central Directory |
|
||||||
|
# | ----------------------------- |
|
||||||
|
# | | 0x06054b50 ( 4B) | |
|
||||||
|
# | | ... (12B) | |
|
||||||
|
# | | CD Offset ( 4B) | |
|
||||||
|
# | | ... | |
|
||||||
|
# | ----------------------------- |
|
||||||
|
# =================================
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: makes certain assumptions and doesn't handle all valid ZIP files!
|
||||||
|
def copy_apk(unsigned_apk, output_apk):
|
||||||
|
"""
|
||||||
|
Copy APK like apksigner would, excluding files matched by
|
||||||
|
exclude_from_copying().
|
||||||
|
|
||||||
|
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)
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(unsigned_apk, "r") as zf:
|
||||||
|
infos = zf.infolist()
|
||||||
|
zdata = zip_data(unsigned_apk)
|
||||||
|
offsets = {}
|
||||||
|
with open(unsigned_apk, "rb") as fhi, open(output_apk, "w+b") as fho:
|
||||||
|
for info in sorted(infos, key=lambda info: info.header_offset):
|
||||||
|
off_i = fhi.tell()
|
||||||
|
if info.header_offset > off_i:
|
||||||
|
# copy extra bytes
|
||||||
|
fho.write(fhi.read(info.header_offset - off_i))
|
||||||
|
hdr = fhi.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])
|
||||||
|
hdr += fhi.read(n + m)
|
||||||
|
skip = exclude_from_copying(info.filename)
|
||||||
|
if skip:
|
||||||
|
fhi.seek(info.compress_size, os.SEEK_CUR)
|
||||||
|
else:
|
||||||
|
if info.filename in offsets:
|
||||||
|
raise ZipError("Duplicate ZIP entry: " + info.filename)
|
||||||
|
offsets[info.filename] = off_o = fho.tell()
|
||||||
|
if info.compress_type == 0 and off_o != info.header_offset:
|
||||||
|
hdr = _realign_zip_entry(info, hdr, n, m, off_o)
|
||||||
|
fho.write(hdr)
|
||||||
|
_copy_bytes(fhi, fho, info.compress_size)
|
||||||
|
if info.flag_bits & 0x08:
|
||||||
|
data_descriptor = fhi.read(12)
|
||||||
|
if data_descriptor[:4] == b"\x50\x4b\x07\x08":
|
||||||
|
data_descriptor += fhi.read(4)
|
||||||
|
if not skip:
|
||||||
|
fho.write(data_descriptor)
|
||||||
|
extra_bytes = zdata.cd_offset - fhi.tell()
|
||||||
|
if copy_extra_bytes:
|
||||||
|
_copy_bytes(fhi, fho, extra_bytes)
|
||||||
|
else:
|
||||||
|
fhi.seek(extra_bytes, os.SEEK_CUR)
|
||||||
|
cd_offset = fho.tell()
|
||||||
|
for info in infos:
|
||||||
|
hdr = fhi.read(46)
|
||||||
|
if hdr[:4] != b"\x50\x4b\x01\x02":
|
||||||
|
raise ZipError("Expected central directory file header signature")
|
||||||
|
n, m, k = struct.unpack("<HHH", hdr[28:34])
|
||||||
|
hdr += fhi.read(n + m + k)
|
||||||
|
if not exclude_from_copying(info.filename):
|
||||||
|
off = int.to_bytes(offsets[info.filename], 4, "little")
|
||||||
|
hdr = hdr[:42] + off + hdr[46:]
|
||||||
|
fho.write(hdr)
|
||||||
|
eocd_offset = fho.tell()
|
||||||
|
fho.write(zdata.cd_and_eocd[zdata.eocd_offset - zdata.cd_offset:])
|
||||||
|
fho.seek(eocd_offset + 8)
|
||||||
|
fho.write(struct.pack("<HHLL", len(offsets), len(offsets),
|
||||||
|
eocd_offset - cd_offset, cd_offset))
|
||||||
|
return max(info.date_time for info in infos)
|
||||||
|
|
||||||
|
|
||||||
|
# NB: doesn't sync local & CD headers!
|
||||||
|
def _realign_zip_entry(info, hdr, n, m, off_o):
|
||||||
|
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
|
||||||
|
new_xtr = b""
|
||||||
|
while len(old_xtr) >= 4:
|
||||||
|
hdr_id, size = struct.unpack("<HH", old_xtr[:4])
|
||||||
|
if size > len(old_xtr) - 4:
|
||||||
|
break
|
||||||
|
if not (hdr_id == 0 and size == 0):
|
||||||
|
if hdr_id == 0xd935:
|
||||||
|
if size >= 2:
|
||||||
|
align = int.from_bytes(old_xtr[4:6], "little")
|
||||||
|
else:
|
||||||
|
new_xtr += old_xtr[:size + 4]
|
||||||
|
old_xtr = old_xtr[size + 4:]
|
||||||
|
if old_off % align == 0 and new_off % align != 0:
|
||||||
|
pad = (align - (new_off - m + len(new_xtr) + 6) % align) % align
|
||||||
|
xtr = new_xtr + struct.pack("<HHH", 0xd935, 2 + pad, align) + pad * b"\x00"
|
||||||
|
m_b = int.to_bytes(len(xtr), 2, "little")
|
||||||
|
hdr = hdr[:28] + m_b + hdr[30:30 + n] + xtr
|
||||||
|
return hdr
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_bytes(fhi, fho, size, blocksize=4096):
|
||||||
|
while size > 0:
|
||||||
|
data = fhi.read(min(size, blocksize))
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
size -= len(data)
|
||||||
|
fho.write(data)
|
||||||
|
if size != 0:
|
||||||
|
raise ZipError("Unexpected EOF")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_meta(signed_apk):
|
||||||
|
"""
|
||||||
|
Extract v1 signature metadata files from signed APK.
|
||||||
|
|
||||||
|
Yields (ZipInfo, data) pairs.
|
||||||
|
"""
|
||||||
|
with zipfile.ZipFile(signed_apk, "r") as zf_sig:
|
||||||
|
for info in zf_sig.infolist():
|
||||||
|
if is_meta(info.filename):
|
||||||
|
yield info, zf_sig.read(info.filename)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_meta(extracted_meta, output_apk, date_time=DATETIMEZERO):
|
||||||
|
"""Add v1 signature metadata to APK (removes v2 sig block, if any)."""
|
||||||
|
with zipfile.ZipFile(output_apk, "r") as zf_out:
|
||||||
|
for info in zf_out.infolist():
|
||||||
|
if is_meta(info.filename):
|
||||||
|
raise ZipError("Unexpected metadata")
|
||||||
|
with zipfile.ZipFile(output_apk, "a") as zf_out:
|
||||||
|
info_data = [(APKZipInfo(info, date_time=date_time), data)
|
||||||
|
for info, data in extracted_meta]
|
||||||
|
_write_to_zip(info_data, zf_out)
|
||||||
|
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 7):
|
||||||
|
def _write_to_zip(info_data, zf_out):
|
||||||
|
for info, data in info_data:
|
||||||
|
zf_out.writestr(info, data, compresslevel=9)
|
||||||
|
else:
|
||||||
|
def _write_to_zip(info_data, zf_out):
|
||||||
|
old = zipfile._get_compressor
|
||||||
|
zipfile._get_compressor = lambda _: zlib.compressobj(9, 8, -15)
|
||||||
|
try:
|
||||||
|
for info, data in info_data:
|
||||||
|
zf_out.writestr(info, data)
|
||||||
|
finally:
|
||||||
|
zipfile._get_compressor = old
|
||||||
|
|
||||||
|
|
||||||
|
def extract_v2_sig(apkfile, expected=True):
|
||||||
|
"""
|
||||||
|
Extract APK Signing Block and offset from APK.
|
||||||
|
|
||||||
|
When successful, returns (sb_offset, sig_block); otherwise raises
|
||||||
|
NoAPKSigningBlock when expected is True, else returns None.
|
||||||
|
"""
|
||||||
|
cd_offset = zip_data(apkfile).cd_offset
|
||||||
|
with open(apkfile, "rb") as fh:
|
||||||
|
fh.seek(cd_offset - 16)
|
||||||
|
if fh.read(16) != b"APK Sig Block 42":
|
||||||
|
if expected:
|
||||||
|
raise NoAPKSigningBlock("No APK Signing Block")
|
||||||
|
return None
|
||||||
|
fh.seek(-24, os.SEEK_CUR)
|
||||||
|
sb_size2 = int.from_bytes(fh.read(8), "little")
|
||||||
|
fh.seek(-sb_size2 + 8, os.SEEK_CUR)
|
||||||
|
sb_size1 = int.from_bytes(fh.read(8), "little")
|
||||||
|
if sb_size1 != sb_size2:
|
||||||
|
raise APKSigningBlockError("APK Signing Block sizes not equal")
|
||||||
|
fh.seek(-8, os.SEEK_CUR)
|
||||||
|
sb_offset = fh.tell()
|
||||||
|
sig_block = fh.read(sb_size2 + 8)
|
||||||
|
return sb_offset, sig_block
|
||||||
|
|
||||||
|
|
||||||
|
def zip_data(apkfile, count=1024):
|
||||||
|
"""
|
||||||
|
Extract central directory, EOCD, and offsets from ZIP.
|
||||||
|
|
||||||
|
Returns ZipData.
|
||||||
|
"""
|
||||||
|
with open(apkfile, "rb") as fh:
|
||||||
|
fh.seek(-count, os.SEEK_END)
|
||||||
|
data = fh.read()
|
||||||
|
pos = data.rfind(b"\x50\x4b\x05\x06")
|
||||||
|
if pos == -1:
|
||||||
|
raise ZipError("Expected end of central directory record (EOCD)")
|
||||||
|
fh.seek(pos - len(data), os.SEEK_CUR)
|
||||||
|
eocd_offset = fh.tell()
|
||||||
|
fh.seek(16, os.SEEK_CUR)
|
||||||
|
cd_offset = int.from_bytes(fh.read(4), "little")
|
||||||
|
fh.seek(cd_offset)
|
||||||
|
cd_and_eocd = fh.read()
|
||||||
|
return ZipData(cd_offset, eocd_offset, cd_and_eocd)
|
||||||
|
|
||||||
|
|
||||||
|
# FIXME: can we determine signed_sb_offset?
|
||||||
|
def patch_v2_sig(extracted_v2_sig, output_apk):
|
||||||
|
"""Implant extracted v2/v3 signature into APK."""
|
||||||
|
signed_sb_offset, signed_sb = extracted_v2_sig
|
||||||
|
data_out = zip_data(output_apk)
|
||||||
|
if signed_sb_offset < data_out.cd_offset:
|
||||||
|
raise APKSigningBlockError("APK Signing Block offset < central directory offset")
|
||||||
|
padding = b"\x00" * (signed_sb_offset - data_out.cd_offset)
|
||||||
|
offset = len(signed_sb) + len(padding)
|
||||||
|
with open(output_apk, "r+b") as fh:
|
||||||
|
fh.seek(data_out.cd_offset)
|
||||||
|
fh.write(padding)
|
||||||
|
fh.write(signed_sb)
|
||||||
|
fh.write(data_out.cd_and_eocd)
|
||||||
|
fh.seek(data_out.eocd_offset + offset + 16)
|
||||||
|
fh.write(int.to_bytes(data_out.cd_offset + offset, 4, "little"))
|
||||||
|
|
||||||
|
|
||||||
|
def patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk):
|
||||||
|
"""
|
||||||
|
Patch extracted_meta + extracted_v2_sig (if not None) onto unsigned_apk and
|
||||||
|
save as output_apk.
|
||||||
|
"""
|
||||||
|
date_time = copy_apk(unsigned_apk, output_apk)
|
||||||
|
patch_meta(extracted_meta, output_apk, date_time=date_time)
|
||||||
|
if extracted_v2_sig is not None:
|
||||||
|
patch_v2_sig(extracted_v2_sig, output_apk)
|
||||||
|
|
||||||
|
|
||||||
|
def do_extract(signed_apk, output_dir, v1_only=NO):
|
||||||
|
"""
|
||||||
|
Extract signatures from signed_apk and save in output_dir.
|
||||||
|
|
||||||
|
The v1_only parameter controls whether the absence of a v1 signature is
|
||||||
|
considered an error or not:
|
||||||
|
* use v1_only=NO (or v1_only=False) to only accept (v1+)v2/v3 signatures;
|
||||||
|
* use v1_only=AUTO (or v1_only=None) to automatically detect v2/v3 signatures;
|
||||||
|
* use v1_only=YES (or v1_only=True) to ignore any v2/v3 signatures.
|
||||||
|
"""
|
||||||
|
v1_only = noautoyes(v1_only)
|
||||||
|
extracted_meta = tuple(extract_meta(signed_apk))
|
||||||
|
if len(extracted_meta) not in (len(META_EXT), 0):
|
||||||
|
raise APKSigCopierError("Unexpected or missing metadata files in signed_apk")
|
||||||
|
for info, data in extracted_meta:
|
||||||
|
name = os.path.basename(info.filename)
|
||||||
|
with open(os.path.join(output_dir, name), "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
if v1_only == YES:
|
||||||
|
if not extracted_meta:
|
||||||
|
raise APKSigCopierError("Expected v1 signature")
|
||||||
|
return
|
||||||
|
expected = v1_only == NO
|
||||||
|
extracted_v2_sig = extract_v2_sig(signed_apk, expected=expected)
|
||||||
|
if extracted_v2_sig is None:
|
||||||
|
if not extracted_meta:
|
||||||
|
raise APKSigCopierError("Expected v1 and/or v2/v3 signature, found neither")
|
||||||
|
return
|
||||||
|
signed_sb_offset, signed_sb = extracted_v2_sig
|
||||||
|
with open(os.path.join(output_dir, SIGOFFSET), "w") as fh:
|
||||||
|
fh.write(str(signed_sb_offset) + "\n")
|
||||||
|
with open(os.path.join(output_dir, SIGBLOCK), "wb") as fh:
|
||||||
|
fh.write(signed_sb)
|
||||||
|
|
||||||
|
|
||||||
|
def do_patch(metadata_dir, unsigned_apk, output_apk, v1_only=NO):
|
||||||
|
"""
|
||||||
|
Patch signatures from metadata_dir onto unsigned_apk and save as output_apk.
|
||||||
|
|
||||||
|
The v1_only parameter controls whether the absence of a v1 signature is
|
||||||
|
considered an error or not:
|
||||||
|
* use v1_only=NO (or v1_only=False) to only accept (v1+)v2/v3 signatures;
|
||||||
|
* use v1_only=AUTO (or v1_only=None) to automatically detect v2/v3 signatures;
|
||||||
|
* use v1_only=YES (or v1_only=True) to ignore any v2/v3 signatures.
|
||||||
|
"""
|
||||||
|
v1_only = noautoyes(v1_only)
|
||||||
|
extracted_meta = []
|
||||||
|
for pat in META_EXT:
|
||||||
|
files = [fn for ext in pat.split("|") for fn in
|
||||||
|
glob.glob(os.path.join(metadata_dir, "*." + ext))]
|
||||||
|
if len(files) != 1:
|
||||||
|
continue
|
||||||
|
info = zipfile.ZipInfo("META-INF/" + os.path.basename(files[0]))
|
||||||
|
with open(files[0], "rb") as fh:
|
||||||
|
extracted_meta.append((info, fh.read()))
|
||||||
|
if len(extracted_meta) not in (len(META_EXT), 0):
|
||||||
|
raise APKSigCopierError("Unexpected or missing files in metadata_dir")
|
||||||
|
if v1_only == YES:
|
||||||
|
extracted_v2_sig = None
|
||||||
|
else:
|
||||||
|
sigoffset_file = os.path.join(metadata_dir, SIGOFFSET)
|
||||||
|
sigblock_file = os.path.join(metadata_dir, SIGBLOCK)
|
||||||
|
if v1_only == AUTO and not os.path.exists(sigblock_file):
|
||||||
|
extracted_v2_sig = None
|
||||||
|
else:
|
||||||
|
with open(sigoffset_file, "r") as fh:
|
||||||
|
signed_sb_offset = int(fh.read())
|
||||||
|
with open(sigblock_file, "rb") as fh:
|
||||||
|
signed_sb = fh.read()
|
||||||
|
extracted_v2_sig = signed_sb_offset, signed_sb
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def do_copy(signed_apk, unsigned_apk, output_apk, v1_only=NO):
|
||||||
|
"""
|
||||||
|
Copy signatures from signed_apk onto unsigned_apk and save as output_apk.
|
||||||
|
|
||||||
|
The v1_only parameter controls whether the absence of a v1 signature is
|
||||||
|
considered an error or not:
|
||||||
|
* use v1_only=NO (or v1_only=False) to only accept (v1+)v2/v3 signatures;
|
||||||
|
* use v1_only=AUTO (or v1_only=None) to automatically detect v2/v3 signatures;
|
||||||
|
* use v1_only=YES (or v1_only=True) to ignore any v2/v3 signatures.
|
||||||
|
"""
|
||||||
|
v1_only = noautoyes(v1_only)
|
||||||
|
extracted_meta = extract_meta(signed_apk)
|
||||||
|
if v1_only == YES:
|
||||||
|
extracted_v2_sig = None
|
||||||
|
else:
|
||||||
|
extracted_v2_sig = extract_v2_sig(signed_apk, expected=v1_only == NO)
|
||||||
|
patch_apk(extracted_meta, extracted_v2_sig, unsigned_apk, output_apk)
|
||||||
|
|
||||||
|
# vim: set tw=80 sw=4 sts=4 et fdm=marker :
|
|
@ -1,8 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
#
|
#
|
||||||
# common.py - part of the FDroid server tools
|
# common.py - part of the FDroid server tools
|
||||||
# Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
|
#
|
||||||
# Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
|
# Copyright (C) 2010-2016, Ciaran Gultnieks, ciaran@ciarang.com
|
||||||
|
# Copyright (C) 2013-2017, Daniel Martí <mvdan@mvdan.cc>
|
||||||
|
# Copyright (C) 2013-2021, Hans-Christoph Steiner <hans@eds.org>
|
||||||
|
# Copyright (C) 2017-2018, Torsten Grote <t@grobox.de>
|
||||||
|
# Copyright (C) 2017, tobiasKaminsky <tobias@kaminsky.me>
|
||||||
|
# Copyright (C) 2017-2021, Michael Pöhn <michael.poehn@fsfe.org>
|
||||||
|
# Copyright (C) 2017,2021, mimi89999 <michel@lebihan.pl>
|
||||||
|
# Copyright (C) 2019-2021, Jochen Sprickerhof <git@jochen.sprickerhof.de>
|
||||||
|
# Copyright (C) 2021, Felix C. Stegerman <flx@obfusk.net>
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -67,6 +75,9 @@ from fdroidserver.exception import FDroidException, VCSException, NoSubmodulesEx
|
||||||
BuildException, VerificationException, MetaDataException
|
BuildException, VerificationException, MetaDataException
|
||||||
from .asynchronousfilereader import AsynchronousFileReader
|
from .asynchronousfilereader import AsynchronousFileReader
|
||||||
|
|
||||||
|
from . import apksigcopier
|
||||||
|
|
||||||
|
|
||||||
# The path to this fdroidserver distribution
|
# The path to this fdroidserver distribution
|
||||||
FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
|
FDROID_PATH = os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))
|
||||||
|
|
||||||
|
@ -2932,13 +2943,13 @@ def apk_signer_fingerprint_short(apk_path):
|
||||||
def metadata_get_sigdir(appid, vercode=None):
|
def metadata_get_sigdir(appid, vercode=None):
|
||||||
"""Get signature directory for app"""
|
"""Get signature directory for app"""
|
||||||
if vercode:
|
if vercode:
|
||||||
return os.path.join('metadata', appid, 'signatures', vercode)
|
return os.path.join('metadata', appid, 'signatures', str(vercode))
|
||||||
else:
|
else:
|
||||||
return os.path.join('metadata', appid, 'signatures')
|
return os.path.join('metadata', appid, 'signatures')
|
||||||
|
|
||||||
|
|
||||||
def metadata_find_developer_signature(appid, vercode=None):
|
def metadata_find_developer_signature(appid, vercode=None):
|
||||||
"""Tires to find the developer signature for given appid.
|
"""Tries to find the developer signature for given appid.
|
||||||
|
|
||||||
This picks the first signature file found in metadata an returns its
|
This picks the first signature file found in metadata an returns its
|
||||||
signature.
|
signature.
|
||||||
|
@ -2960,45 +2971,63 @@ def metadata_find_developer_signature(appid, vercode=None):
|
||||||
appversigdirs.append(appversigdir)
|
appversigdirs.append(appversigdir)
|
||||||
|
|
||||||
for sigdir in appversigdirs:
|
for sigdir in appversigdirs:
|
||||||
sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
|
signature_block_files = (
|
||||||
glob.glob(os.path.join(sigdir, '*.EC')) + \
|
glob.glob(os.path.join(sigdir, '*.DSA'))
|
||||||
glob.glob(os.path.join(sigdir, '*.RSA'))
|
+ glob.glob(os.path.join(sigdir, '*.EC'))
|
||||||
if len(sigs) > 1:
|
+ glob.glob(os.path.join(sigdir, '*.RSA'))
|
||||||
|
)
|
||||||
|
if len(signature_block_files) > 1:
|
||||||
raise FDroidException('ambiguous signatures, please make sure there is only one signature in \'{}\'. (The signature has to be the App maintainers signature for version of the APK.)'.format(sigdir))
|
raise FDroidException('ambiguous signatures, please make sure there is only one signature in \'{}\'. (The signature has to be the App maintainers signature for version of the APK.)'.format(sigdir))
|
||||||
for sig in sigs:
|
for signature_block_file in signature_block_files:
|
||||||
with open(sig, 'rb') as f:
|
with open(signature_block_file, 'rb') as f:
|
||||||
return signer_fingerprint(get_certificate(f.read()))
|
return signer_fingerprint(get_certificate(f.read()))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def metadata_find_signing_files(appid, vercode):
|
def metadata_find_signing_files(appid, vercode):
|
||||||
"""Gets a list of singed manifests and signatures.
|
"""Gets a list of signed manifests and signatures.
|
||||||
|
|
||||||
|
https://docs.oracle.com/javase/tutorial/deployment/jar/intro.html
|
||||||
|
https://source.android.com/security/apksigning/v2
|
||||||
|
https://source.android.com/security/apksigning/v3
|
||||||
|
|
||||||
:param appid: app id string
|
:param appid: app id string
|
||||||
:param vercode: app version code
|
:param vercode: app version code
|
||||||
:returns: a list of triplets for each signing key with following paths:
|
:returns: a list of 4-tuples for each signing key with following paths:
|
||||||
(signature_file, singed_file, manifest_file)
|
(signature_file, signature_block_file, manifest, v2_files), where v2_files
|
||||||
|
is either a (apk_signing_block_offset_file, apk_signing_block_file) pair or None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
ret = []
|
ret = []
|
||||||
sigdir = metadata_get_sigdir(appid, vercode)
|
sigdir = metadata_get_sigdir(appid, vercode)
|
||||||
sigs = glob.glob(os.path.join(sigdir, '*.DSA')) + \
|
signature_block_files = (
|
||||||
glob.glob(os.path.join(sigdir, '*.EC')) + \
|
glob.glob(os.path.join(sigdir, '*.DSA'))
|
||||||
glob.glob(os.path.join(sigdir, '*.RSA'))
|
+ glob.glob(os.path.join(sigdir, '*.EC'))
|
||||||
extre = re.compile(r'(\.DSA|\.EC|\.RSA)$')
|
+ glob.glob(os.path.join(sigdir, '*.RSA'))
|
||||||
for sig in sigs:
|
)
|
||||||
sf = extre.sub('.SF', sig)
|
signature_block_pat = re.compile(r'(\.DSA|\.EC|\.RSA)$')
|
||||||
if os.path.isfile(sf):
|
apk_signing_block = os.path.isfile(os.path.join(sigdir, "APKSigningBlock"))
|
||||||
mf = os.path.join(sigdir, 'MANIFEST.MF')
|
apk_signing_block_offset = os.path.isfile(os.path.join(sigdir, "APKSigningBlockOffset"))
|
||||||
if os.path.isfile(mf):
|
if os.path.isfile(apk_signing_block) and os.path.isfile(apk_signing_block_offset):
|
||||||
ret.append((sig, sf, mf))
|
v2_files = apk_signing_block, apk_signing_block_offset
|
||||||
|
else:
|
||||||
|
v2_files = None
|
||||||
|
for signature_block_file in signature_block_files:
|
||||||
|
signature_file = signature_block_pat.sub('.SF', signature_block_file)
|
||||||
|
if os.path.isfile(signature_file):
|
||||||
|
manifest = os.path.join(sigdir, 'MANIFEST.MF')
|
||||||
|
if os.path.isfile(manifest):
|
||||||
|
ret.append((signature_block_file, signature_file, manifest, v2_files))
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
def metadata_find_developer_signing_files(appid, vercode):
|
def metadata_find_developer_signing_files(appid, vercode):
|
||||||
"""Get developer signature files for specified app from metadata.
|
"""Get developer signature files for specified app from metadata.
|
||||||
|
|
||||||
:returns: A triplet of paths for signing files from metadata:
|
:returns: a list of 4-tuples for each signing key with following paths:
|
||||||
(signature_file, singed_file, manifest_file)
|
(signature_file, signature_block_file, manifest, v2_files), where v2_files
|
||||||
|
is either a (apk_signing_block_offset_file, apk_signing_block_file) pair or None
|
||||||
|
|
||||||
"""
|
"""
|
||||||
allsigningfiles = metadata_find_signing_files(appid, vercode)
|
allsigningfiles = metadata_find_signing_files(appid, vercode)
|
||||||
if allsigningfiles and len(allsigningfiles) == 1:
|
if allsigningfiles and len(allsigningfiles) == 1:
|
||||||
|
@ -3007,6 +3036,36 @@ def metadata_find_developer_signing_files(appid, vercode):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ClonedZipInfo(zipfile.ZipInfo):
|
||||||
|
"""Hack to allow fully cloning ZipInfo instances
|
||||||
|
|
||||||
|
The zipfile library has some bugs that prevent it from fully
|
||||||
|
cloning ZipInfo entries. https://bugs.python.org/issue43547
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, zinfo):
|
||||||
|
self.original = zinfo
|
||||||
|
for k in self.__slots__:
|
||||||
|
try:
|
||||||
|
setattr(self, k, getattr(zinfo, k))
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __getattribute__(self, name):
|
||||||
|
if name in ("date_time", "external_attr", "flag_bits"):
|
||||||
|
return getattr(self.original, name)
|
||||||
|
return object.__getattribute__(self, name)
|
||||||
|
|
||||||
|
|
||||||
|
def apk_has_v1_signatures(apkfile):
|
||||||
|
"""Test whether an APK has v1 signature files."""
|
||||||
|
with ZipFile(apkfile, 'r') as apk:
|
||||||
|
for info in apk.infolist():
|
||||||
|
if APK_SIGNATURE_FILES.match(info.filename):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def apk_strip_v1_signatures(signed_apk, strip_manifest=False):
|
def apk_strip_v1_signatures(signed_apk, strip_manifest=False):
|
||||||
"""Removes signatures from APK.
|
"""Removes signatures from APK.
|
||||||
|
|
||||||
|
@ -3024,10 +3083,10 @@ def apk_strip_v1_signatures(signed_apk, strip_manifest=False):
|
||||||
if strip_manifest:
|
if strip_manifest:
|
||||||
if info.filename != 'META-INF/MANIFEST.MF':
|
if info.filename != 'META-INF/MANIFEST.MF':
|
||||||
buf = in_apk.read(info.filename)
|
buf = in_apk.read(info.filename)
|
||||||
out_apk.writestr(info, buf)
|
out_apk.writestr(ClonedZipInfo(info), buf)
|
||||||
else:
|
else:
|
||||||
buf = in_apk.read(info.filename)
|
buf = in_apk.read(info.filename)
|
||||||
out_apk.writestr(info, buf)
|
out_apk.writestr(ClonedZipInfo(info), buf)
|
||||||
|
|
||||||
|
|
||||||
def _zipalign(unsigned_apk, aligned_apk):
|
def _zipalign(unsigned_apk, aligned_apk):
|
||||||
|
@ -3042,49 +3101,36 @@ def _zipalign(unsigned_apk, aligned_apk):
|
||||||
raise BuildException("Failed to align application")
|
raise BuildException("Failed to align application")
|
||||||
|
|
||||||
|
|
||||||
def apk_implant_signatures(apkpath, signaturefile, signedfile, manifest):
|
def apk_implant_signatures(apkpath, outpath, manifest):
|
||||||
"""Implants a signature from metadata into an APK.
|
"""Implants a signature from metadata into an APK.
|
||||||
|
|
||||||
Note: this changes there supplied APK in place. So copy it if you
|
Note: this changes there supplied APK in place. So copy it if you
|
||||||
need the original to be preserved.
|
need the original to be preserved.
|
||||||
|
|
||||||
:param apkpath: location of the apk
|
https://docs.oracle.com/javase/tutorial/deployment/jar/intro.html
|
||||||
|
https://source.android.com/security/apksigning/v2
|
||||||
|
https://source.android.com/security/apksigning/v3
|
||||||
|
|
||||||
|
:param apkpath: location of the unsigned apk
|
||||||
|
:param outpath: location of the output apk
|
||||||
"""
|
"""
|
||||||
# get list of available signature files in metadata
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
sigdir = os.path.dirname(manifest) # FIXME
|
||||||
apkwithnewsig = os.path.join(tmpdir, 'newsig.apk')
|
apksigcopier.do_patch(sigdir, apkpath, outpath, v1_only=None)
|
||||||
with ZipFile(apkpath, 'r') as in_apk:
|
|
||||||
with ZipFile(apkwithnewsig, 'w') as out_apk:
|
|
||||||
for sig_file in [signaturefile, signedfile, manifest]:
|
|
||||||
with open(sig_file, 'rb') as fp:
|
|
||||||
buf = fp.read()
|
|
||||||
info = zipfile.ZipInfo('META-INF/' + os.path.basename(sig_file))
|
|
||||||
info.compress_type = zipfile.ZIP_DEFLATED
|
|
||||||
info.create_system = 0 # "Windows" aka "FAT", what Android SDK uses
|
|
||||||
out_apk.writestr(info, buf)
|
|
||||||
for info in in_apk.infolist():
|
|
||||||
if not APK_SIGNATURE_FILES.match(info.filename):
|
|
||||||
if info.filename != 'META-INF/MANIFEST.MF':
|
|
||||||
buf = in_apk.read(info.filename)
|
|
||||||
out_apk.writestr(info, buf)
|
|
||||||
os.remove(apkpath)
|
|
||||||
_zipalign(apkwithnewsig, apkpath)
|
|
||||||
|
|
||||||
|
|
||||||
def apk_extract_signatures(apkpath, outdir, manifest=True):
|
def apk_extract_signatures(apkpath, outdir):
|
||||||
"""Extracts a signature files from APK and puts them into target directory.
|
"""Extracts a signature files from APK and puts them into target directory.
|
||||||
|
|
||||||
|
https://docs.oracle.com/javase/tutorial/deployment/jar/intro.html
|
||||||
|
https://source.android.com/security/apksigning/v2
|
||||||
|
https://source.android.com/security/apksigning/v3
|
||||||
|
|
||||||
:param apkpath: location of the apk
|
:param apkpath: location of the apk
|
||||||
:param outdir: folder where the extracted signature files will be stored
|
:param outdir: folder where the extracted signature files will be stored
|
||||||
:param manifest: (optionally) disable extracting manifest file
|
|
||||||
"""
|
"""
|
||||||
with ZipFile(apkpath, 'r') as in_apk:
|
apksigcopier.do_extract(apkpath, outdir, v1_only=None)
|
||||||
for f in in_apk.infolist():
|
|
||||||
if APK_SIGNATURE_FILES.match(f.filename) or \
|
|
||||||
(manifest and f.filename == 'META-INF/MANIFEST.MF'):
|
|
||||||
newpath = os.path.join(outdir, os.path.basename(f.filename))
|
|
||||||
with open(newpath, 'wb') as out_file:
|
|
||||||
out_file.write(in_apk.read(f.filename))
|
|
||||||
|
|
||||||
|
|
||||||
def get_min_sdk_version(apk):
|
def get_min_sdk_version(apk):
|
||||||
|
@ -3147,11 +3193,11 @@ def sign_apk(unsigned_path, signed_path, keyalias):
|
||||||
os.remove(unsigned_path)
|
os.remove(unsigned_path)
|
||||||
|
|
||||||
|
|
||||||
def verify_apks(signed_apk, unsigned_apk, tmp_dir):
|
def verify_apks(signed_apk, unsigned_apk, tmp_dir, v1_only=None):
|
||||||
"""Verify that two apks are the same
|
"""Verify that two apks are the same
|
||||||
|
|
||||||
One of the inputs is signed, the other is unsigned. The signature metadata
|
One of the inputs is signed, the other is unsigned. The signature metadata
|
||||||
is transferred from the signed to the unsigned apk, and then jarsigner is
|
is transferred from the signed to the unsigned apk, and then apksigner is
|
||||||
used to verify that the signature from the signed APK is also valid for
|
used to verify that the signature from the signed APK is also valid for
|
||||||
the unsigned one. If the APK given as unsigned actually does have a
|
the unsigned one. If the APK given as unsigned actually does have a
|
||||||
signature, it will be stripped out and ignored.
|
signature, it will be stripped out and ignored.
|
||||||
|
@ -3159,53 +3205,38 @@ def verify_apks(signed_apk, unsigned_apk, tmp_dir):
|
||||||
:param signed_apk: Path to a signed APK file
|
:param signed_apk: Path to a signed APK file
|
||||||
:param unsigned_apk: Path to an unsigned APK file expected to match it
|
:param unsigned_apk: Path to an unsigned APK file expected to match it
|
||||||
:param tmp_dir: Path to directory for temporary files
|
:param tmp_dir: Path to directory for temporary files
|
||||||
|
:param v1_only: True for v1-only signatures, False for v1 and v2 signatures,
|
||||||
|
or None for autodetection
|
||||||
:returns: None if the verification is successful, otherwise a string
|
:returns: None if the verification is successful, otherwise a string
|
||||||
describing what went wrong.
|
describing what went wrong.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if not verify_apk_signature(signed_apk):
|
||||||
|
logging.info('...NOT verified - {0}'.format(signed_apk))
|
||||||
|
return 'verification of signed APK failed'
|
||||||
|
|
||||||
if not os.path.isfile(signed_apk):
|
if not os.path.isfile(signed_apk):
|
||||||
return 'can not verify: file does not exists: {}'.format(signed_apk)
|
return 'can not verify: file does not exists: {}'.format(signed_apk)
|
||||||
|
|
||||||
if not os.path.isfile(unsigned_apk):
|
if not os.path.isfile(unsigned_apk):
|
||||||
return 'can not verify: file does not exists: {}'.format(unsigned_apk)
|
return 'can not verify: file does not exists: {}'.format(unsigned_apk)
|
||||||
|
|
||||||
with ZipFile(signed_apk, 'r') as signed:
|
|
||||||
meta_inf_files = ['META-INF/MANIFEST.MF']
|
|
||||||
for f in signed.namelist():
|
|
||||||
if APK_SIGNATURE_FILES.match(f):
|
|
||||||
meta_inf_files.append(f)
|
|
||||||
if len(meta_inf_files) < 3:
|
|
||||||
return "Signature files missing from {0}".format(signed_apk)
|
|
||||||
|
|
||||||
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))
|
||||||
with ZipFile(unsigned_apk, 'r') as unsigned:
|
|
||||||
# only read the signature from the signed APK, everything else from unsigned
|
|
||||||
with ZipFile(tmp_apk, 'w') as tmp:
|
|
||||||
for filename in meta_inf_files:
|
|
||||||
tmp.writestr(signed.getinfo(filename), signed.read(filename))
|
|
||||||
for info in unsigned.infolist():
|
|
||||||
if info.filename in meta_inf_files:
|
|
||||||
logging.warning('Ignoring %s from %s', info.filename, unsigned_apk)
|
|
||||||
continue
|
|
||||||
if info.filename in tmp.namelist():
|
|
||||||
return "duplicate filename found: " + info.filename
|
|
||||||
tmp.writestr(info, unsigned.read(info.filename))
|
|
||||||
|
|
||||||
# Use jarsigner to verify the v1 signature on the reproduced APK, as
|
|
||||||
# apksigner will reject the reproduced APK if the original also had a v2
|
|
||||||
# signature
|
|
||||||
try:
|
try:
|
||||||
verify_jar_signature(tmp_apk)
|
apksigcopier.do_copy(signed_apk, unsigned_apk, tmp_apk, v1_only=v1_only)
|
||||||
verified = True
|
except apksigcopier.APKSigCopierError as e:
|
||||||
except Exception:
|
logging.info('...NOT verified - {0}'.format(tmp_apk))
|
||||||
verified = False
|
return 'signature copying failed: {}'.format(str(e))
|
||||||
|
|
||||||
if not verified:
|
if not verify_apk_signature(tmp_apk):
|
||||||
logging.info("...NOT verified - {0}".format(tmp_apk))
|
logging.info('...NOT verified - {0}'.format(tmp_apk))
|
||||||
return compare_apks(signed_apk, tmp_apk, tmp_dir,
|
result = compare_apks(signed_apk, tmp_apk, tmp_dir,
|
||||||
os.path.dirname(unsigned_apk))
|
os.path.dirname(unsigned_apk))
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
return 'verification of APK with copied signature failed'
|
||||||
|
|
||||||
logging.info("...successfully verified")
|
logging.info('...successfully verified')
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
# publish.py - part of the FDroid server tools
|
# publish.py - part of the FDroid server tools
|
||||||
# Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
|
# Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
|
||||||
# Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
|
# Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
|
||||||
|
# Copyright (C) 2021 Felix C. Stegerman <flx@obfusk.net>
|
||||||
#
|
#
|
||||||
# This program is free software: you can redistribute it and/or modify
|
# This program is free software: you can redistribute it and/or modify
|
||||||
# it under the terms of the GNU Affero General Public License as published by
|
# it under the terms of the GNU Affero General Public License as published by
|
||||||
|
@ -345,16 +346,14 @@ def main():
|
||||||
# metadata. This means we're going to prepare both a locally
|
# metadata. This means we're going to prepare both a locally
|
||||||
# signed APK and a version signed with the developers key.
|
# signed APK and a version signed with the developers key.
|
||||||
|
|
||||||
signaturefile, signedfile, manifest = signingfiles
|
signature_file, _ignored, manifest, v2_files = signingfiles
|
||||||
|
|
||||||
with open(signaturefile, 'rb') as f:
|
with open(signature_file, 'rb') as f:
|
||||||
devfp = common.signer_fingerprint_short(common.get_certificate(f.read()))
|
devfp = common.signer_fingerprint_short(common.get_certificate(f.read()))
|
||||||
devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
|
devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
|
||||||
devsignedtmp = os.path.join(tmp_dir, devsigned)
|
devsignedtmp = os.path.join(tmp_dir, devsigned)
|
||||||
shutil.copy(apkfile, devsignedtmp)
|
|
||||||
|
|
||||||
common.apk_implant_signatures(devsignedtmp, signaturefile,
|
common.apk_implant_signatures(apkfile, devsignedtmp, manifest=manifest)
|
||||||
signedfile, manifest)
|
|
||||||
if common.verify_apk_signature(devsignedtmp):
|
if common.verify_apk_signature(devsignedtmp):
|
||||||
shutil.move(devsignedtmp, os.path.join(output_dir, devsigned))
|
shutil.move(devsignedtmp, os.path.join(output_dir, devsigned))
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -95,10 +95,25 @@ else
|
||||||
sed -i '/^wiki_/d' config.yml
|
sed -i '/^wiki_/d' config.yml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
printf '\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nbuild all with reproducible signatures\n'
|
||||||
|
for f in metadata/*/signatures/*; do
|
||||||
|
appid=$(basename $(dirname $(dirname $f)))
|
||||||
|
versionCode=$(basename $f)
|
||||||
|
rm -f repo/${appid}_* archive/${appid}_* unsigned/${appid}_*
|
||||||
|
$WORKSPACE/fdroid build --verbose --latest --no-tarball ${appid}:$versionCode
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nbuild all with Binaries:\n'
|
||||||
|
for appid in `grep '^Binaries: ' metadata/*.yml --files-with-match | sed 's,^metadata/\(.*\)\.yml$,\1,'`; do
|
||||||
|
rm -f repo/${appid}_* archive/${appid}_* unsigned/${appid}_*
|
||||||
|
$WORKSPACE/fdroid build --verbose --latest --no-tarball ${appid}
|
||||||
|
done
|
||||||
|
|
||||||
# force global timeout to 6 hours
|
# force global timeout to 6 hours
|
||||||
sed -Ei 's,^(\s+endtime\s*=\s*time\.time\(\))\s*.*,\1 + 6 * 60 * 60 # 6 hours,' \
|
sed -Ei 's,^(\s+endtime\s*=\s*time\.time\(\))\s*.*,\1 + 6 * 60 * 60 # 6 hours,' \
|
||||||
$WORKSPACE/fdroidserver/build.py
|
$WORKSPACE/fdroidserver/build.py
|
||||||
|
|
||||||
|
printf '\n@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@\nbuild all\n'
|
||||||
$WORKSPACE/fdroid build --verbose --latest --no-tarball --all $wikiflag
|
$WORKSPACE/fdroid build --verbose --latest --no-tarball --all $wikiflag
|
||||||
|
|
||||||
vagrant global-status
|
vagrant global-status
|
||||||
|
|
|
@ -473,7 +473,7 @@ class CommonTest(unittest.TestCase):
|
||||||
for info in apk.infolist():
|
for info in apk.infolist():
|
||||||
testapk.writestr(info, apk.read(info.filename))
|
testapk.writestr(info, apk.read(info.filename))
|
||||||
if info.filename.startswith('META-INF/'):
|
if info.filename.startswith('META-INF/'):
|
||||||
testapk.writestr(info, otherapk.read(info.filename))
|
testapk.writestr(info.filename, otherapk.read(info.filename))
|
||||||
otherapk.close()
|
otherapk.close()
|
||||||
self.assertFalse(fdroidserver.common.verify_apk_signature(twosigapk))
|
self.assertFalse(fdroidserver.common.verify_apk_signature(twosigapk))
|
||||||
self.assertIsNone(fdroidserver.common.verify_apks(sourceapk, twosigapk, self.tmpdir))
|
self.assertIsNone(fdroidserver.common.verify_apks(sourceapk, twosigapk, self.tmpdir))
|
||||||
|
@ -1754,6 +1754,42 @@ class CommonTest(unittest.TestCase):
|
||||||
fdroidserver.common.read_pkg_args(appid_versionCode_pairs, allow_vercodes)
|
fdroidserver.common.read_pkg_args(appid_versionCode_pairs, allow_vercodes)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_apk_strip_v1_signatures(self):
|
||||||
|
testdir = tempfile.mkdtemp(prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir)
|
||||||
|
before = os.path.join(self.basedir, 'no_targetsdk_minsdk1_unsigned.apk')
|
||||||
|
after = os.path.join(testdir, 'after.apk')
|
||||||
|
shutil.copy(before, after)
|
||||||
|
fdroidserver.common.apk_strip_v1_signatures(after, strip_manifest=False)
|
||||||
|
|
||||||
|
def test_metadata_find_developer_signing_files(self):
|
||||||
|
appid = 'org.smssecure.smssecure'
|
||||||
|
|
||||||
|
self.assertIsNone(
|
||||||
|
fdroidserver.common.metadata_find_developer_signing_files(appid, 133)
|
||||||
|
)
|
||||||
|
|
||||||
|
vc = '135'
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, '28969C09.RSA'),
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, '28969C09.SF'),
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, 'MANIFEST.MF'),
|
||||||
|
None
|
||||||
|
),
|
||||||
|
fdroidserver.common.metadata_find_developer_signing_files(appid, vc)
|
||||||
|
)
|
||||||
|
|
||||||
|
vc = '134'
|
||||||
|
self.assertEqual(
|
||||||
|
(
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, '28969C09.RSA'),
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, '28969C09.SF'),
|
||||||
|
os.path.join('metadata', appid, 'signatures', vc, 'MANIFEST.MF'),
|
||||||
|
None
|
||||||
|
),
|
||||||
|
fdroidserver.common.metadata_find_developer_signing_files(appid, vc)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.chdir(os.path.dirname(__file__))
|
os.chdir(os.path.dirname(__file__))
|
||||||
|
|
|
@ -30,6 +30,8 @@ if localmodule not in sys.path:
|
||||||
|
|
||||||
from fdroidserver import publish
|
from fdroidserver import publish
|
||||||
from fdroidserver import common
|
from fdroidserver import common
|
||||||
|
from fdroidserver import metadata
|
||||||
|
from fdroidserver import signatures
|
||||||
from fdroidserver.exception import FDroidException
|
from fdroidserver.exception import FDroidException
|
||||||
|
|
||||||
|
|
||||||
|
@ -250,6 +252,71 @@ class PublishTest(unittest.TestCase):
|
||||||
self.assertEqual(publish.config['jarsigner'], data['jarsigner'])
|
self.assertEqual(publish.config['jarsigner'], data['jarsigner'])
|
||||||
self.assertEqual(publish.config['keytool'], data['keytool'])
|
self.assertEqual(publish.config['keytool'], data['keytool'])
|
||||||
|
|
||||||
|
def test_sign_then_implant_signature(self):
|
||||||
|
|
||||||
|
class Options:
|
||||||
|
verbose = False
|
||||||
|
|
||||||
|
testdir = tempfile.mkdtemp(prefix=inspect.currentframe().f_code.co_name, dir=self.tmpdir)
|
||||||
|
os.chdir(testdir)
|
||||||
|
|
||||||
|
config = common.read_config(Options)
|
||||||
|
if 'apksigner' not in config:
|
||||||
|
self.skipTest('SKIPPING test_sign_then_implant_signature, apksigner not installed!')
|
||||||
|
config['repo_keyalias'] = 'sova'
|
||||||
|
config['keystorepass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI='
|
||||||
|
config['keypass'] = 'r9aquRHYoI8+dYz6jKrLntQ5/NJNASFBacJh7Jv2BlI='
|
||||||
|
shutil.copy(os.path.join(self.basedir, 'keystore.jks'), testdir)
|
||||||
|
config['keystore'] = 'keystore.jks'
|
||||||
|
config['keydname'] = 'CN=Birdman, OU=Cell, O=Alcatraz, L=Alcatraz, S=California, C=US'
|
||||||
|
publish.config = config
|
||||||
|
common.config = config
|
||||||
|
|
||||||
|
app = metadata.App()
|
||||||
|
app.id = 'org.fdroid.ci'
|
||||||
|
versionCode = 1
|
||||||
|
build = metadata.Build(
|
||||||
|
{
|
||||||
|
'versionCode': versionCode,
|
||||||
|
'versionName': '1.0',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
app.Builds = [build]
|
||||||
|
os.mkdir('metadata')
|
||||||
|
metadata.write_metadata(os.path.join('metadata', '%s.yml' % app.id), app)
|
||||||
|
|
||||||
|
os.mkdir('unsigned')
|
||||||
|
testapk = os.path.join(self.basedir, 'no_targetsdk_minsdk1_unsigned.apk')
|
||||||
|
unsigned = os.path.join('unsigned', common.get_release_filename(app, build))
|
||||||
|
signed = os.path.join('repo', common.get_release_filename(app, build))
|
||||||
|
shutil.copy(testapk, unsigned)
|
||||||
|
|
||||||
|
# sign the unsigned APK
|
||||||
|
self.assertTrue(os.path.exists(unsigned))
|
||||||
|
self.assertFalse(os.path.exists(signed))
|
||||||
|
with mock.patch('sys.argv', ['fdroid publish', '%s:%d' % (app.id, versionCode)]):
|
||||||
|
publish.main()
|
||||||
|
self.assertFalse(os.path.exists(unsigned))
|
||||||
|
self.assertTrue(os.path.exists(signed))
|
||||||
|
|
||||||
|
with mock.patch('sys.argv', ['fdroid signatures', signed]):
|
||||||
|
signatures.main()
|
||||||
|
self.assertTrue(
|
||||||
|
os.path.exists(
|
||||||
|
os.path.join('metadata', 'org.fdroid.ci', 'signatures', '1', 'MANIFEST.MF')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
os.remove(signed)
|
||||||
|
|
||||||
|
# implant the signature into the unsigned APK
|
||||||
|
shutil.copy(testapk, unsigned)
|
||||||
|
self.assertTrue(os.path.exists(unsigned))
|
||||||
|
self.assertFalse(os.path.exists(signed))
|
||||||
|
with mock.patch('sys.argv', ['fdroid publish', '%s:%d' % (app.id, versionCode)]):
|
||||||
|
publish.main()
|
||||||
|
self.assertFalse(os.path.exists(unsigned))
|
||||||
|
self.assertTrue(os.path.exists(signed))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
os.chdir(os.path.dirname(__file__))
|
os.chdir(os.path.dirname(__file__))
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue