From 82e644fa17806459b1b150274749aff3efb08d69 Mon Sep 17 00:00:00 2001 From: Timothy Mario Redaelli Date: Wed, 3 Jun 2026 20:51:49 +0000 Subject: [PATCH] common, publish: support v2/v3-only sigdirs in developer signature graft --- fdroidserver/common.py | 35 +++++++- fdroidserver/publish.py | 19 ++-- tests/test_common.py | 193 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 9 deletions(-) diff --git a/fdroidserver/common.py b/fdroidserver/common.py index 94de9c12..9e3ef794 100644 --- a/fdroidserver/common.py +++ b/fdroidserver/common.py @@ -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): diff --git a/fdroidserver/publish.py b/fdroidserver/publish.py index b6c0009a..8ada34cb 100644 --- a/fdroidserver/publish.py +++ b/fdroidserver/publish.py @@ -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: diff --git a/tests/test_common.py b/tests/test_common.py index c0ad7cc8..4bef611b 100755 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -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(' 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"""