Merge branch 'getcert' into 'master'

get_first_signer_certificate: check all v1 v2 and v3 certs

See merge request fdroid/fdroidserver!1466
This commit is contained in:
linsui 2024-04-19 17:55:21 +00:00
commit a90d82226c
6 changed files with 325 additions and 38 deletions

View File

@ -54,16 +54,13 @@ from pathlib import Path
import defusedxml.ElementTree as XMLElementTree
from asn1crypto import cms
from base64 import urlsafe_b64encode
from binascii import hexlify
from datetime import datetime, timedelta, timezone
from queue import Queue
from zipfile import ZipFile
from pyasn1.codec.der import decoder, encoder
from pyasn1_modules import rfc2315
from pyasn1.error import PyAsn1Error
import fdroidserver.metadata
import fdroidserver.lint
from fdroidserver import _
@ -3148,32 +3145,79 @@ def signer_fingerprint(cert_encoded):
def get_first_signer_certificate(apkpath):
"""Get the first signing certificate from the APK, DER-encoded."""
certs = None
cert_encoded = None
with zipfile.ZipFile(apkpath, 'r') as apk:
cert_files = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(cert_files) > 1:
logging.error(_("Found multiple JAR Signature Block Files in {path}").format(path=apkpath))
return None
elif len(cert_files) == 1:
cert_encoded = get_certificate(apk.read(cert_files[0]))
"""Get the first signing certificate from the APK, DER-encoded.
if not cert_encoded and use_androguard():
apkobject = _get_androguard_APK(apkpath)
certs = apkobject.get_certificates_der_v2()
if len(certs) > 0:
logging.debug(_('Using APK Signature v2'))
cert_encoded = certs[0]
JAR and APK Signatures allow for multiple signers, though it is
rarely used, and this is poorly documented. So this method only
fetches the first certificate, and errors out if there are more.
Starting with SDK 30, APK v2 Signatures are required.
https://developer.android.com/about/versions/11/behavior-changes-11#minimum-signature-scheme
When a APK v2 signature is present, the certificate in the JAR
signature is ignored. The verifier parses the signers from the v2
signature and does not seem to look at the JAR signature.
https://source.android.com/docs/security/features/apksigning/v2#apk-signature-scheme-v2-block
https://android.googlesource.com/platform/tools/apksig/+/refs/tags/android-13.0.0_r3/src/main/java/com/android/apksig/ApkVerifier.java#270
apksigner checks that the signers from all the APK signatures match:
https://android.googlesource.com/platform/tools/apksig/+/refs/tags/android-13.0.0_r3/src/main/java/com/android/apksig/ApkVerifier.java#383
NoOverwriteDict is a workaround for:
https://github.com/androguard/androguard/issues/1030
Lots more discusion here:
https://gitlab.com/fdroid/fdroidserver/-/issues/1128
"""
class NoOverwriteDict(dict):
def __setitem__(self, k, v):
if k not in self:
super().__setitem__(k, v)
cert_encoded = None
found_certs = []
apkobject = _get_androguard_APK(apkpath)
apkobject._v2_blocks = NoOverwriteDict()
certs_v3 = apkobject.get_certificates_der_v3()
if certs_v3:
cert_v3 = certs_v3[0]
found_certs.append(cert_v3)
if not cert_encoded:
certs = apkobject.get_certificates_der_v3()
if len(certs) > 0:
logging.debug(_('Using APK Signature v3'))
cert_encoded = certs[0]
logging.debug(_('Using APK Signature v3'))
cert_encoded = cert_v3
certs_v2 = apkobject.get_certificates_der_v2()
if certs_v2:
cert_v2 = certs_v2[0]
found_certs.append(cert_v2)
if not cert_encoded:
logging.debug(_('Using APK Signature v2'))
cert_encoded = cert_v2
if get_min_sdk_version(apkobject) < 30:
with zipfile.ZipFile(apkpath, 'r') as apk:
cert_files = [n for n in apk.namelist() if SIGNATURE_BLOCK_FILE_REGEX.match(n)]
if len(cert_files) > 1:
logging.error(_("Found multiple JAR Signature Block Files in {path}").format(path=apkpath))
return None
elif len(cert_files) == 1:
cert_v1 = get_certificate(apk.read(cert_files[0]))
found_certs.append(cert_v1)
if not cert_encoded:
logging.debug(_('Using JAR Signature'))
cert_encoded = cert_v1
if not cert_encoded:
logging.error(_("No signing certificates found in {path}").format(path=apkpath))
return None
if not all(cert == found_certs[0] for cert in found_certs):
logging.error(
_("APK signatures have different certificates in {path}:").format(
path=apkpath
)
)
return cert_encoded
@ -3863,6 +3907,17 @@ def get_cert_fingerprint(pubkey):
def get_certificate(signature_block_file):
"""Extract a DER certificate from JAR Signature's "Signature Block File".
Rarely, there is a certificate chain like in TLS (Microsoft does
this). In that case, this returns only the one certificate which
apksigner returns. This will be the same certificate that will be
printed by:
apksigner verify --print-certs com.microsoft.emmx.apk
This could be replaced by androguard's APK.get_certificate_der()
provided the cert chain fix was merged there. Maybe in 4.1.2?
https://github.com/androguard/androguard/pull/1038
Parameters
----------
signature_block_file
@ -3875,18 +3930,8 @@ def get_certificate(signature_block_file):
or None in case of error
"""
content = decoder.decode(signature_block_file, asn1Spec=rfc2315.ContentInfo())[0]
if content.getComponentByName('contentType') != rfc2315.signedData:
return None
content = decoder.decode(content.getComponentByName('content'),
asn1Spec=rfc2315.SignedData())[0]
try:
certificates = content.getComponentByName('certificates')
cert = certificates[0].getComponentByName('certificate')
except PyAsn1Error:
logging.error("Certificates not found.")
return None
return encoder.encode(cert)
pkcs7obj = cms.ContentInfo.load(signature_block_file)
return pkcs7obj['content']['certificates'][-1].chosen.dump()
def load_stats_fdroid_signing_key_fingerprints():

View File

@ -93,14 +93,13 @@ setup(
install_requires=[
'appdirs',
'androguard >= 3.1.0, != 3.3.0, != 3.3.1, != 3.3.2, <4',
'asn1crypto',
'clint',
'defusedxml',
'GitPython',
'paramiko',
'Pillow',
'apache-libcloud >= 0.14.1',
'pyasn1 >=0.4.1',
'pyasn1-modules >= 0.2.1',
'python-vagrant',
'PyYAML',
'qrcode',

BIN
tests/MSFTSIG.RSA Normal file

Binary file not shown.

View File

@ -615,6 +615,25 @@ class CommonTest(unittest.TestCase):
self.assertFalse(fdroidserver.common.verify_apk_signature(twosigapk))
self.assertIsNone(fdroidserver.common.verify_apks(sourceapk, twosigapk, self.tmpdir))
def test_get_certificate_with_chain(self):
"""Test that APK signatures with a cert chain are parsed like apksigner.
Microsoft signs their APKs with a X.509 certificate chain of
trust, so there are actually three certificates
included. apksigner only cares about one certificate in the
chain and ignores the rest.
The correct value comes from:
apksigner verify --print-certs com.microsoft.emmx_242009005.apk | grep SHA-256
"""
with open('MSFTSIG.RSA', 'rb') as fp:
cert = fdroidserver.common.get_certificate(fp.read())
self.assertEqual(
'01e1999710a82c2749b4d50c445dc85d670b6136089d0a766a73827c82a1eac9',
fdroidserver.common.signer_fingerprint(cert),
)
def test_write_to_config(self):
with tempfile.TemporaryDirectory() as tmpPath:
cfgPath = os.path.join(tmpPath, 'config.py')
@ -2915,6 +2934,218 @@ class CommonTest(unittest.TestCase):
)
APKS_WITH_JAR_SIGNATURES = (
(
'SpeedoMeterApp.main_1.apk',
'2e6b3126fb7e0db6a9d4c2a06df690620655454d6e152cf244cc9efe9787a77d',
),
(
'SystemWebView-repack.apk',
'b5358886cf36cadab87bc992da9f9016ae9370bdd019e48ffb930674d5ed27c4',
),
(
'apk.embedded_1.apk',
'764f0eaac0cdcde35023658eea865c4383ab580f9827c62fdd3daf9e654199ee',
),
(
'bad-unicode-πÇÇ现代通用字-български-عربي1.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'issue-1128-poc.apk',
'09350d5f3460a8a0ea5cf6b68ccd296a58754f7e683ba6aa08c19be8353504f3',
),
(
'janus.apk',
'ebb0fedf1942a099b287c3db00ff732162152481abb2b6c7cbcdb2ba5894a768',
),
(
'org.bitbucket.tickytacky.mirrormirror_1.apk',
'feaa63df35b4635cf091513dfcd6d11209632555efdfc47e33b70d4e4eb5ba28',
),
(
'org.bitbucket.tickytacky.mirrormirror_2.apk',
'feaa63df35b4635cf091513dfcd6d11209632555efdfc47e33b70d4e4eb5ba28',
),
(
'org.bitbucket.tickytacky.mirrormirror_3.apk',
'feaa63df35b4635cf091513dfcd6d11209632555efdfc47e33b70d4e4eb5ba28',
),
(
'org.bitbucket.tickytacky.mirrormirror_4.apk',
'feaa63df35b4635cf091513dfcd6d11209632555efdfc47e33b70d4e4eb5ba28',
),
(
'org.dyndns.fules.ck_20.apk',
'9326a2cc1a2f148202bc7837a0af3b81200bd37fd359c9e13a2296a71d342056',
),
(
'org.sajeg.fallingblocks_3.apk',
'033389681f4288fdb3e72a28058c8506233ca50de75452ab6c9c76ea1ca2d70f',
),
(
'repo/com.example.test.helloworld_1.apk',
'c3a5ca5465a7585a1bda30218ae4017083605e3576867aa897d724208d99696c',
),
(
'repo/com.politedroid_3.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/com.politedroid_4.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/com.politedroid_5.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/com.politedroid_6.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/duplicate.permisssions_9999999.apk',
'659e1fd284549f70d13fb02c620100e27eeea3420558cce62b0f5d4cf2b77d84',
),
(
'repo/info.zwanenburg.caffeinetile_4.apk',
'51cfa5c8a743833ad89acf81cb755936876a5c8b8eca54d1ffdcec0cdca25d0e',
),
(
'repo/no.min.target.sdk_987.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/obb.main.oldversion_1444412523.apk',
'818e469465f96b704e27be2fee4c63ab9f83ddf30e7a34c7371a4728d83b0bc1',
),
(
'repo/obb.main.twoversions_1101613.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/obb.main.twoversions_1101615.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/obb.main.twoversions_1101617.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/obb.mainpatch.current_1619.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/obb.mainpatch.current_1619_another-release-key.apk',
'ce9e200667f02d96d49891a2e08a3c178870e91853d61bdd33ef5f0b54701aa5',
),
(
'repo/souch.smsbypass_9.apk',
'd3aec784b1fd71549fc22c999789122e3639895db6bd585da5835fbe3db6985c',
),
(
'repo/urzip-; Рахма́, [rɐxˈmanʲɪnəf] سيرجي_رخمانينوف 谢·.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'repo/v1.v2.sig_1020.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'urzip-release.apk',
'32a23624c201b949f085996ba5ed53d40f703aca4989476949cae891022e0ed6',
),
(
'urzip.apk',
'7eabd8c15de883d1e82b5df2fd4f7f769e498078e9ad6dc901f0e96db77ceac3',
),
)
class SignerExtractionText(unittest.TestCase):
"""Test extraction of the signer certificate from JARs and APKs
These fingerprints can be confirmed with:
apksigner verify --print-certs foo.apk | grep SHA-256
keytool -printcert -file ____.RSA
"""
def setUp(self):
os.chdir(os.path.join(localmodule, 'tests'))
self._td = mkdtemp()
self.testdir = self._td.name
self.apksigner = shutil.which('apksigner')
self.keytool = shutil.which('keytool')
def tearDown(self):
self._td.cleanup()
def test_get_first_signer_certificate_with_jars(self):
for jar in (
'signindex/guardianproject-v1.jar',
'signindex/guardianproject.jar',
'signindex/testy.jar',
):
outdir = os.path.join(self.testdir, jar[:-4].replace('/', '_'))
os.mkdir(outdir)
fdroidserver.common.apk_extract_signatures(jar, outdir)
certs = glob.glob(os.path.join(outdir, '*.RSA'))
with open(certs[0], 'rb') as fp:
self.assertEqual(
fdroidserver.common.get_certificate(fp.read()),
fdroidserver.common.get_first_signer_certificate(jar),
)
@unittest.skip("slow and only needed when adding to APKS_WITH_JAR_SIGNATURES")
def test_vs_keytool(self):
unittest.skipUnless(self.keytool, 'requires keytool to run')
pat = re.compile(r'[0-9A-F:]{95}')
cmd = [self.keytool, '-printcert', '-jarfile']
for apk, fingerprint in APKS_WITH_JAR_SIGNATURES:
o = subprocess.check_output(cmd + [apk], text=True)
try:
self.assertEqual(
fingerprint,
pat.search(o).group().replace(':', '').lower(),
)
except AttributeError as e:
print(e, o)
@unittest.skip("slow and only needed when adding to APKS_WITH_JAR_SIGNATURES")
def test_vs_apksigner(self):
unittest.skipUnless(self.apksigner, 'requires apksigner to run')
pat = re.compile(r'\s[0-9a-f]{64}\s')
cmd = [self.apksigner, 'verify', '--print-certs']
for apk, fingerprint in APKS_WITH_JAR_SIGNATURES:
output = subprocess.check_output(cmd + [apk], text=True)
self.assertEqual(
fingerprint,
pat.search(output).group().strip(),
apk + " should have matching signer fingerprints",
)
def test_apk_signer_fingerprint_with_v1_apks(self):
for apk, fingerprint in APKS_WITH_JAR_SIGNATURES:
outdir = os.path.join(self.testdir, apk[:-4].replace('/', '_'))
os.mkdir(outdir)
try:
fdroidserver.common.apk_extract_signatures(apk, outdir)
except fdroidserver.apksigcopier.APKSigCopierError as e:
# nothing to test when this error is thrown
continue
certs = glob.glob(os.path.join(outdir, '*.[DR]SA'))
with open(certs[0], 'rb') as fp:
self.assertEqual(
fingerprint,
fdroidserver.common.apk_signer_fingerprint(apk),
)
def test_get_first_signer_certificate_with_unsigned_jar(self):
self.assertIsNone(
fdroidserver.common.get_first_signer_certificate('signindex/unsigned.jar')
)
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))

View File

@ -2,6 +2,7 @@
import copy
import datetime
import glob
import inspect
import logging
import optparse
@ -418,6 +419,17 @@ class IndexTest(unittest.TestCase):
self.maxDiff = None
self.assertEqual(json.dumps(i, indent=2), json.dumps(o, indent=2))
# and test it still works with get_first_signer_certificate
outdir = os.path.join(self.testdir, 'publishsigkeys')
os.mkdir(outdir)
common.apk_extract_signatures(jarfile, outdir)
certs = glob.glob(os.path.join(outdir, '*.RSA'))
with open(certs[0], 'rb') as fp:
self.assertEqual(
common.get_certificate(fp.read()),
common.get_first_signer_certificate(jarfile),
)
def test_make_v0_repo_only(self):
os.chdir(self.testdir)
os.mkdir('repo')

BIN
tests/issue-1128-poc.apk Normal file

Binary file not shown.