79 lines
4.2 KiB
Python
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}))
|