Files
TPSchedule/scripts/signing-info.py
T

126 lines
5.4 KiB
Python

"""Export public APK signing information and verify continuity. No private keys."""
import argparse
import hashlib
import json
import struct
import zipfile
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.serialization import pkcs7
from cryptography import x509
def length_prefixed(data, offset=0):
size = struct.unpack_from('<I', data, offset)[0]
end = offset + 4 + size
if end > len(data):
raise ValueError('Malformed APK signing record.')
return data[offset + 4:end], end
def modern_certificate(apk):
# APK v2/v3 certificates live before the ZIP central directory, not META-INF.
with open(apk, 'rb') as stream:
stream.seek(0, 2)
size = stream.tell()
stream.seek(max(0, size - 65557))
tail = stream.read()
eocd = tail.rfind(b'PK\x05\x06')
if eocd < 0:
raise ValueError('Missing ZIP directory.')
central = struct.unpack_from('<I', tail, eocd + 16)[0]
stream.seek(central - 24)
footer = stream.read(24)
if footer[8:] != b'APK Sig Block 42':
return None
block_size = struct.unpack_from('<Q', footer)[0]
if block_size < 24 or block_size + 8 > central:
raise ValueError('Malformed APK signing block.')
stream.seek(central - block_size - 8)
block = stream.read(block_size + 8)
if struct.unpack_from('<Q', block)[0] != block_size:
raise ValueError('APK signing block sizes differ.')
offset = 8
while offset < len(block) - 24:
pair_size = struct.unpack_from('<Q', block, offset)[0]
offset += 8
pair = block[offset:offset + pair_size]
if pair_size < 4 or len(pair) != pair_size:
raise ValueError('Malformed APK signer pair.')
scheme = struct.unpack_from('<I', pair)[0]
if scheme in (0x7109871A, 0xF05368C0, 0x1B93AD61):
signers, _ = length_prefixed(pair[4:])
signer, signer_end = length_prefixed(signers)
if signer_end != len(signers):
raise ValueError('Multiple APK signers are not supported by this report.')
signed_data, _ = length_prefixed(signer)
_, digest_end = length_prefixed(signed_data)
certificates, _ = length_prefixed(signed_data, digest_end)
cert_der, _ = length_prefixed(certificates)
return x509.load_der_x509_certificate(cert_der)
offset += pair_size
return None
def certificate(apk):
modern = modern_certificate(apk)
if modern is not None:
return modern
with zipfile.ZipFile(apk) as archive:
blocks = [name for name in archive.namelist()
if name.upper().startswith('META-INF/')
and name.upper().endswith(('.RSA', '.EC', '.DSA'))]
if len(blocks) != 1:
raise ValueError('Expected one v1 signing certificate block; verify APK with apksigner.')
certificates = pkcs7.load_der_pkcs7_certificates(archive.read(blocks[0]))
if len(certificates) != 1:
raise ValueError('Expected one signing certificate, not a certificate chain.')
return certificates[0]
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--previous', required=True)
parser.add_argument('--current', required=True)
parser.add_argument('--output-dir', required=True)
args = parser.parse_args()
previous = certificate(args.previous)
current = certificate(args.current)
old_der = previous.public_bytes(serialization.Encoding.DER)
der = current.public_bytes(serialization.Encoding.DER)
public = current.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
if old_der != der:
raise ValueError('Signing certificate changed. Existing signing material must be preserved.')
output = Path(args.output_dir)
output.mkdir(parents=True, exist_ok=True)
details = {
'appName': 'TPSchedule',
'applicationId': 'com.tpschedule.app',
'certificateUnchanged': True,
'subject': current.subject.rfc4514_string(),
'certificateSHA256': hashlib.sha256(der).hexdigest().upper(),
'certificateSHA1': hashlib.sha1(der).hexdigest().upper(),
'certificateMD5': hashlib.md5(der).hexdigest().upper(),
'publicKeySHA256': hashlib.sha256(public).hexdigest().upper(),
'publicKeyMD5': hashlib.md5(public).hexdigest().upper(),
'publicKeySPKIHex': public.hex().upper(),
'publicKeyBits': current.public_key().key_size,
'certificateNote': 'Existing Android Debug certificate preserved; store/filing acceptance must be checked.',
}
(output / 'TPSchedule-signing-info.json').write_text(
json.dumps(details, ensure_ascii=False, indent=2), encoding='utf-8')
(output / 'TPSchedule-signing-certificate.pem').write_bytes(
current.public_bytes(serialization.Encoding.PEM))
(output / 'TPSchedule-public-key.pem').write_bytes(
current.public_key().public_bytes(serialization.Encoding.PEM,
serialization.PublicFormat.SubjectPublicKeyInfo))
print(json.dumps({'certificateUnchanged': True,
'certificateSHA256': details['certificateSHA256'],
'publicFiles': str(output)}, ensure_ascii=True))
if __name__ == '__main__':
main()