mirror of
https://github.com/f-droid/fdroidserver.git
synced 2026-06-24 08:38:47 -04:00
common, publish: support v2/v3-only sigdirs in developer signature graft
This commit is contained in:
committed by
Hans-Christoph Steiner
parent
993016ef61
commit
82e644fa17
@@ -3642,6 +3642,12 @@ def metadata_find_developer_signature(appid, vercode=None):
|
||||
for signature_block_file in signature_block_files:
|
||||
with open(signature_block_file, 'rb') as f:
|
||||
return signer_fingerprint(get_certificate(f.read()))
|
||||
# No v1 (JAR) signature: fall back to the cached developer certificate
|
||||
# written by apk_extract_signatures for v2/v3-only sigdirs.
|
||||
# See https://gitlab.com/fdroid/fdroidserver/-/issues/1065
|
||||
signer_cert_der = Path(sigdir) / 'signer-certificate.der'
|
||||
if signer_cert_der.is_file():
|
||||
return signer_fingerprint(signer_cert_der.read_bytes())
|
||||
return None
|
||||
|
||||
|
||||
@@ -3688,6 +3694,14 @@ def metadata_find_signing_files(appid, vercode):
|
||||
manifest = os.path.join(sigdir, 'MANIFEST.MF')
|
||||
if os.path.isfile(manifest):
|
||||
ret.append((signature_block_file, signature_file, manifest, v2_files))
|
||||
if not ret and v2_files is not None and os.path.isfile(
|
||||
os.path.join(sigdir, 'signer-certificate.der')
|
||||
):
|
||||
# APK is v2/v3-signed only (no v1 META-INF). The APKSigningBlock plus
|
||||
# the cached signer-certificate.der are needed; a sigdir missing signer-certificate.der
|
||||
# is treated as no signing entry so partial state never reaches publish.
|
||||
# See https://gitlab.com/fdroid/fdroidserver/-/issues/1065
|
||||
ret.append((None, None, None, v2_files))
|
||||
return ret
|
||||
|
||||
|
||||
@@ -3767,7 +3781,7 @@ def apk_strip_v1_signatures(signed_apk, strip_manifest=False):
|
||||
out_apk.writestr(ClonedZipInfo(info), buf)
|
||||
|
||||
|
||||
def apk_implant_signatures(apkpath, outpath, manifest):
|
||||
def apk_implant_signatures(apkpath, outpath, sigdir):
|
||||
"""Implant a signature from metadata into an APK.
|
||||
|
||||
Note: this changes there supplied APK in place. So copy it if you
|
||||
@@ -3779,6 +3793,9 @@ def apk_implant_signatures(apkpath, outpath, manifest):
|
||||
location of the unsigned apk
|
||||
outpath
|
||||
location of the output apk
|
||||
sigdir
|
||||
path to the metadata signature directory containing the v1 META-INF
|
||||
files and/or the APK Signing Block to graft.
|
||||
|
||||
References
|
||||
----------
|
||||
@@ -3787,7 +3804,6 @@ def apk_implant_signatures(apkpath, outpath, manifest):
|
||||
* https://source.android.com/security/apksigning/v3
|
||||
|
||||
"""
|
||||
sigdir = os.path.dirname(manifest) # FIXME
|
||||
apksigcopier.do_patch(sigdir, apkpath, outpath, v1_only=None,
|
||||
exclude=apksigcopier.exclude_meta)
|
||||
|
||||
@@ -3810,6 +3826,21 @@ def apk_extract_signatures(apkpath, outdir):
|
||||
|
||||
"""
|
||||
apksigcopier.do_extract(apkpath, outdir, v1_only=None)
|
||||
# For v2/v3-only APKs (no v1 META-INF) the developer fingerprint can no
|
||||
# longer be read from a .RSA/.DSA/.EC file, so cache the first signer's
|
||||
# certificate (DER) via get_first_signer_certificate alongside the block.
|
||||
# See https://gitlab.com/fdroid/fdroidserver/-/issues/1065
|
||||
has_v2_block = os.path.isfile(os.path.join(outdir, 'APKSigningBlock'))
|
||||
has_v1_sig = any(
|
||||
glob.glob(os.path.join(outdir, '*.' + ext)) for ext in ('RSA', 'DSA', 'EC')
|
||||
)
|
||||
if has_v2_block and not has_v1_sig:
|
||||
cert = get_first_signer_certificate(apkpath)
|
||||
if cert is None:
|
||||
raise FDroidException(
|
||||
_('No v2/v3 certificate found in {path}').format(path=apkpath)
|
||||
)
|
||||
Path(os.path.join(outdir, 'signer-certificate.der')).write_bytes(cert)
|
||||
|
||||
|
||||
def get_min_sdk_version(apk):
|
||||
|
||||
@@ -421,16 +421,21 @@ def main():
|
||||
# metadata. This means we're going to prepare both a locally
|
||||
# signed APK and a version signed with the developers key.
|
||||
|
||||
signature_file, _ignored, manifest, v2_files = signingfiles
|
||||
|
||||
with open(signature_file, 'rb') as f:
|
||||
devfp = common.signer_fingerprint_short(
|
||||
common.get_certificate(f.read())
|
||||
)
|
||||
signature_block_file, _ignored, _manifest, v2_files = signingfiles
|
||||
sigdir = common.metadata_get_sigdir(appid, vercode)
|
||||
if signature_block_file is not None:
|
||||
with open(signature_block_file, 'rb') as f:
|
||||
cert = common.get_certificate(f.read())
|
||||
else:
|
||||
with open(
|
||||
os.path.join(sigdir, 'signer-certificate.der'), 'rb'
|
||||
) as f:
|
||||
cert = f.read()
|
||||
devfp = common.signer_fingerprint_short(cert)
|
||||
devsigned = '{}_{}_{}.apk'.format(appid, vercode, devfp)
|
||||
devsignedtmp = os.path.join(tmp_dir, devsigned)
|
||||
|
||||
common.apk_implant_signatures(apkfile, devsignedtmp, manifest=manifest)
|
||||
common.apk_implant_signatures(apkfile, devsignedtmp, sigdir)
|
||||
if common.verify_apk_signature(devsignedtmp):
|
||||
shutil.move(devsignedtmp, os.path.join(output_dir, devsigned))
|
||||
else:
|
||||
|
||||
@@ -2448,6 +2448,199 @@ class CommonTest(SetUpTearDownMixin, unittest.TestCase):
|
||||
fdroidserver.common.metadata_find_developer_signing_files(appid, vc),
|
||||
)
|
||||
|
||||
# APK Signing Block format constants (see Android docs links below).
|
||||
# Kept in the tests rather than fdroidserver.common because runtime
|
||||
# certificate extraction goes through androguard; this oracle exists
|
||||
# only to cross-check androguard's parser against the on-disk block.
|
||||
# https://source.android.com/docs/security/features/apksigning/v2
|
||||
# https://source.android.com/docs/security/features/apksigning/v3
|
||||
_APK_SIG_BLOCK_MAGIC = b'APK Sig Block 42'
|
||||
_APK_SIG_BLOCK_ID_V2 = 0x7109871A
|
||||
_APK_SIG_BLOCK_ID_V3 = 0xF05368C0
|
||||
|
||||
@staticmethod
|
||||
def _build_apk_signing_block(cert_der):
|
||||
"""Construct a minimal APK Signing Block carrying one v2 signer with cert."""
|
||||
import struct
|
||||
|
||||
certs_section = struct.pack('<I', len(cert_der)) + cert_der
|
||||
signed_data = (
|
||||
struct.pack('<I', 0) # digests
|
||||
+ struct.pack('<I', len(certs_section))
|
||||
+ certs_section
|
||||
+ struct.pack('<I', 0) # additional attributes
|
||||
)
|
||||
signer = (
|
||||
struct.pack('<I', len(signed_data)) + signed_data
|
||||
+ struct.pack('<I', 0) # signatures
|
||||
+ struct.pack('<I', 0) # public key
|
||||
)
|
||||
signers = struct.pack('<I', len(signer)) + signer
|
||||
v2_value = struct.pack('<I', len(signers)) + signers
|
||||
pair_len = 4 + len(v2_value) # u32 id + value
|
||||
pair = (
|
||||
struct.pack('<Q', pair_len)
|
||||
+ struct.pack('<I', CommonTest._APK_SIG_BLOCK_ID_V2)
|
||||
+ v2_value
|
||||
)
|
||||
leading_size = len(pair) + 8 + 16 # pairs + trailing size + magic
|
||||
return (
|
||||
struct.pack('<Q', leading_size)
|
||||
+ pair
|
||||
+ struct.pack('<Q', leading_size)
|
||||
+ CommonTest._APK_SIG_BLOCK_MAGIC
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _oracle_read_length_prefixed(buf, off):
|
||||
if len(buf) - off < 4:
|
||||
raise ValueError('truncated length prefix')
|
||||
n = int.from_bytes(buf[off:off + 4], 'little')
|
||||
end = off + 4 + n
|
||||
if end > len(buf):
|
||||
raise ValueError('truncated slice')
|
||||
return buf[off + 4:end], end
|
||||
|
||||
@classmethod
|
||||
def _oracle_first_cert_from_signer_block(cls, value):
|
||||
signers, _ = cls._oracle_read_length_prefixed(value, 0)
|
||||
signer, _ = cls._oracle_read_length_prefixed(signers, 0)
|
||||
signed_data, _ = cls._oracle_read_length_prefixed(signer, 0)
|
||||
_digests, off = cls._oracle_read_length_prefixed(signed_data, 0)
|
||||
certs, _ = cls._oracle_read_length_prefixed(signed_data, off)
|
||||
cert, _ = cls._oracle_read_length_prefixed(certs, 0)
|
||||
return cert
|
||||
|
||||
@classmethod
|
||||
def _oracle_get_certificate_from_apk_signing_block(cls, block_path):
|
||||
"""Parse a raw APK Signing Block file and return first signer's first cert (DER).
|
||||
|
||||
Independent oracle used to cross-check androguard's parser; not used
|
||||
at runtime by fdroidserver itself.
|
||||
"""
|
||||
with open(block_path, 'rb') as f:
|
||||
block = f.read()
|
||||
if len(block) < 32 or not block.endswith(cls._APK_SIG_BLOCK_MAGIC):
|
||||
raise ValueError('not an APK Signing Block')
|
||||
declared_size = int.from_bytes(block[0:8], 'little')
|
||||
if declared_size + 8 != len(block):
|
||||
raise ValueError('size mismatch')
|
||||
pairs_end = len(block) - 24
|
||||
v2_value = v3_value = None
|
||||
p = 8
|
||||
while p < pairs_end:
|
||||
if pairs_end - p < 12:
|
||||
raise ValueError('truncated ID-value pair')
|
||||
pair_len = int.from_bytes(block[p:p + 8], 'little')
|
||||
if pair_len < 4 or p + 8 + pair_len > pairs_end:
|
||||
raise ValueError('invalid ID-value pair length')
|
||||
pair_id = int.from_bytes(block[p + 8:p + 12], 'little')
|
||||
if pair_id == cls._APK_SIG_BLOCK_ID_V3:
|
||||
v3_value = block[p + 12:p + 8 + pair_len]
|
||||
break
|
||||
if pair_id == cls._APK_SIG_BLOCK_ID_V2:
|
||||
v2_value = block[p + 12:p + 8 + pair_len]
|
||||
p += 8 + pair_len
|
||||
value = v3_value if v3_value is not None else v2_value
|
||||
if value is None:
|
||||
raise ValueError('no v2/v3 block')
|
||||
return cls._oracle_first_cert_from_signer_block(value)
|
||||
|
||||
def test_oracle_get_certificate_from_apk_signing_block(self):
|
||||
import hashlib
|
||||
|
||||
cert_der = b'fake-x509-cert-bytes-' + bytes(range(64))
|
||||
block = self._build_apk_signing_block(cert_der)
|
||||
with tempfile.NamedTemporaryFile(delete=False) as fh:
|
||||
fh.write(block)
|
||||
block_path = fh.name
|
||||
try:
|
||||
extracted = self._oracle_get_certificate_from_apk_signing_block(
|
||||
block_path
|
||||
)
|
||||
self.assertEqual(cert_der, extracted)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(cert_der).hexdigest(),
|
||||
fdroidserver.common.signer_fingerprint(extracted),
|
||||
)
|
||||
finally:
|
||||
os.unlink(block_path)
|
||||
|
||||
def test_oracle_get_certificate_from_apk_signing_block_invalid(self):
|
||||
with tempfile.NamedTemporaryFile(delete=False) as fh:
|
||||
fh.write(b'not an APK Signing Block')
|
||||
bogus = fh.name
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
self._oracle_get_certificate_from_apk_signing_block(bogus)
|
||||
finally:
|
||||
os.unlink(bogus)
|
||||
|
||||
@unittest.skipIf(sys.byteorder == 'big', 'androguard is not ported to big-endian')
|
||||
def test_signer_cert_matches_signing_block_oracle(self):
|
||||
"""The cert fdroidserver caches must match an independent block parser.
|
||||
|
||||
Cross-check ``get_first_signer_certificate`` (the production androguard
|
||||
wrapper, with the issue #1030 ``NoOverwriteDict`` workaround) against
|
||||
the standalone APK Signing Block oracle parser. Bypassing the wrapper
|
||||
and calling ``get_certificates_der_v3()`` / ``_v2()`` directly is
|
||||
fragile across androguard versions: e.g. androguard 3.4.0a1 returns
|
||||
the wrong signer for ``issue-1128-poc2.apk`` without the workaround.
|
||||
|
||||
Uses ``issue-1128-poc2.apk`` (v3-only, no META-INF) since the other
|
||||
v2/v3-only fixtures carry partial META-INF that apksigcopier rejects.
|
||||
"""
|
||||
apk = 'issue-1128-poc2.apk'
|
||||
apkpath = os.path.join(basedir, apk)
|
||||
outdir = os.path.join(self.testdir, apk[:-4])
|
||||
os.mkdir(outdir)
|
||||
fdroidserver.common.apk_extract_signatures(apkpath, outdir)
|
||||
block_path = os.path.join(outdir, 'APKSigningBlock')
|
||||
self.assertTrue(os.path.isfile(block_path))
|
||||
oracle_cert = self._oracle_get_certificate_from_apk_signing_block(
|
||||
block_path
|
||||
)
|
||||
wrapper_cert = fdroidserver.common.get_first_signer_certificate(apkpath)
|
||||
self.assertEqual(wrapper_cert, oracle_cert)
|
||||
with open(os.path.join(outdir, 'signer-certificate.der'), 'rb') as f:
|
||||
self.assertEqual(f.read(), oracle_cert)
|
||||
|
||||
def test_metadata_find_signing_files_v2_only(self):
|
||||
"""v2/v3-only sigdir (APKSigningBlock + offset + signer-certificate.der, no v1 META-INF)."""
|
||||
cert_der = b'v2-only-cert-' + bytes(range(64))
|
||||
block = self._build_apk_signing_block(cert_der)
|
||||
appid = 'com.example.v2only'
|
||||
vc = '42'
|
||||
with tempfile.TemporaryDirectory() as tmpdir, TmpCwd(tmpdir):
|
||||
sigdir = os.path.join('metadata', appid, 'signatures', vc)
|
||||
os.makedirs(sigdir)
|
||||
block_path = os.path.join(sigdir, 'APKSigningBlock')
|
||||
offset_path = os.path.join(sigdir, 'APKSigningBlockOffset')
|
||||
der_path = os.path.join(sigdir, 'signer-certificate.der')
|
||||
with open(block_path, 'wb') as fh:
|
||||
fh.write(block)
|
||||
with open(offset_path, 'w') as fh:
|
||||
fh.write('1234')
|
||||
with open(der_path, 'wb') as fh:
|
||||
fh.write(cert_der)
|
||||
|
||||
self.assertEqual(
|
||||
[(None, None, None, (block_path, offset_path))],
|
||||
fdroidserver.common.metadata_find_signing_files(appid, vc),
|
||||
)
|
||||
self.assertEqual(
|
||||
(None, None, None, (block_path, offset_path)),
|
||||
fdroidserver.common.metadata_find_developer_signing_files(
|
||||
appid, vc
|
||||
),
|
||||
)
|
||||
import hashlib
|
||||
|
||||
self.assertEqual(
|
||||
hashlib.sha256(cert_der).hexdigest(),
|
||||
fdroidserver.common.metadata_find_developer_signature(appid, vc),
|
||||
)
|
||||
|
||||
@mock.patch('sdkmanager.build_package_list', lambda use_net: None)
|
||||
def test_auto_install_ndk(self):
|
||||
"""Test all possible field data types for build.ndk"""
|
||||
|
||||
Reference in New Issue
Block a user