the-custodian/docs/changes/CUST-WP-0073/ubuntu-dependencies-r1/verify_closure.py

79 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Verify InRelease -> Packages -> .deb hashes, plus exact solver closure."""
from pathlib import Path
import datetime
import hashlib
import json
import re
import subprocess
BASE=Path(__file__).resolve().parent
def paragraphs(text):
for paragraph in text.strip().split('\n\n'):
result={};key=None
for line in paragraph.splitlines():
if line.startswith(' '):
if key is None: raise ValueError('orphan continuation')
result[key]+='\n'+line[1:]
else:
key,value=line.split(':',1)
if key in result:raise ValueError('duplicate field')
result[key]=value.strip()
yield result
def sha(data):return hashlib.sha256(data).hexdigest()
def verify():
releases=[];records=[]
for release in sorted((BASE/'lists').glob('*_InRelease')):
p=subprocess.run(['gpgv','--keyring','/usr/share/keyrings/ubuntu-archive-keyring.gpg',
'--status-fd','2','--output','-',str(release)],capture_output=True,check=True)
status=p.stderr.decode()
fingerprints=re.findall(r'\[GNUPG:\] VALIDSIG ([A-F0-9]+)',status)
if not fingerprints:raise ValueError('missing valid signer')
fields=next(paragraphs(p.stdout.decode()))
hashes={l.split()[2]:(l.split()[0],int(l.split()[1]))
for l in fields['SHA256'].splitlines() if l.strip()}
index=release.with_name(release.name.removesuffix('InRelease')+'main_binary-amd64_Packages')
if not index.exists():index=index.with_name(index.name+'.lz4')
raw=subprocess.check_output(['/usr/lib/apt/apt-helper','cat-file',str(index)])
digest,size=hashes['main/binary-amd64/Packages']
if (sha(raw),len(raw))!=(digest,size):raise ValueError('package index hash mismatch')
for record in paragraphs(raw.decode()):
if 'SHA256' in record:record['_suite']=fields['Suite'];records.append(record)
releases.append({'file':release.name,'sha256':sha(release.read_bytes()),
'suite':fields['Suite'],'date':fields.get('Date'),
'valid_until':fields.get('Valid-Until'),
'signers':fingerprints,'packages_sha256':digest})
if len(releases)!=3:raise ValueError('expected all three signed suites')
pins=json.loads((BASE/'package-pins.json').read_text())
verified=[]
for deb in sorted((BASE/'archives').glob('*.deb')):
data=deb.read_bytes();digest=sha(data)
candidates=[r for r in records if r['SHA256']==digest and int(r['Size'])==len(data)]
if not candidates:raise ValueError('untrusted package bytes')
r=candidates[0]
identity=subprocess.check_output(['dpkg-deb','--show','--showformat=${Package}\t${Version}\t${Architecture}',str(deb)],text=True).split('\t')
if identity!=[r['Package'],r['Version'],r['Architecture']]:raise ValueError('identity mismatch')
verified.append({'package':r['Package'],'version':r['Version'],'architecture':r['Architecture'],
'sha256':digest,'bytes':len(data),'local_file':str(deb.relative_to(BASE)),
'repository_path':r['Filename'],'signed_suites':sorted({c['_suite'] for c in candidates})})
actual={r['package']+'='+r['version'] for r in verified}
expected={p.replace(':amd64=','=') for p in pins}
if actual!=expected or len(verified)!=len(expected):raise ValueError('download closure differs from plan')
rootfs=BASE/'rootfs.wsl'
h=hashlib.file_digest(rootfs.open('rb'),'sha256').hexdigest()
if h!='bb415d824822c4b878125729af451a5d18fb13d1cf5cbed9a7393ad64ac6039e':raise ValueError('rootfs mismatch')
return {'capture_utc':datetime.datetime.now(datetime.timezone.utc).isoformat(),
'rootfs_sha256':h,'dpkg_status_sha256':sha((BASE/'rootfs-dpkg-status').read_bytes()),
'repositories':releases,'packages':verified,'package_count':len(verified),
'package_download_bytes':sum(p['bytes'] for p in verified),'installed':False}
if __name__=='__main__':
receipt=verify();(BASE/'verification-manifest.json').write_text(json.dumps(receipt,indent=2)+'\n')
print(json.dumps({'verified_packages':receipt['package_count'],'installed':False}))