149 lines
11 KiB
Python
149 lines
11 KiB
Python
"""Real signed manifests, CA reissuance, HTTPS RRDP and rsync across CLI restarts."""
|
|
from __future__ import annotations
|
|
import argparse, base64, functools, hashlib, http.server, json, shutil, ssl, subprocess, sys, tempfile, threading
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives import serialization
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.hazmat.primitives.serialization import Encoding
|
|
import generate_repository as g
|
|
from test_public_config import free_port, make_rsync_wrapper, read_json, run_checked, write_config
|
|
|
|
|
|
def create_cases(fixture: Path, port: int) -> list[dict]:
|
|
g.STRICT_NAMES = True
|
|
g.RRDP_HOST = '127.0.0.1'; g.RRDP_PORT = port
|
|
g.RRDP_URI = f'https://127.0.0.1:{port}/rrdp/notification.xml'
|
|
g.ROOT_REPO_URI = 'rsync://127.0.0.1:873/custom/root/'
|
|
g.CHILD_REPO_URI = 'rsync://127.0.0.1:873/custom/child/'
|
|
root_key = serialization.load_pem_private_key((fixture/'keys/root.key').read_bytes(), None)
|
|
child_key = serialization.load_pem_private_key((fixture/'keys/child.key').read_bytes(), None)
|
|
original = fixture/'cases/baseline-v1/repository'
|
|
root = x509.load_der_x509_certificate((original/'ta.cer').read_bytes())
|
|
child = x509.load_der_x509_certificate((original/'root/child.cer').read_bytes())
|
|
cases = []
|
|
# Rejections are followed by valid retries against the same durable history.
|
|
specs = [
|
|
('initial', 'a.mft', 2**159-1, 100, ['a.mft'], None, 1, ''),
|
|
('renamed', 'b.mft', 1, 101, ['b.mft'], None, 1, 'manifest filename changed'),
|
|
('stale-rename', 'c.mft', 0, 100, ['c.mft'], None, 0, 'thisUpdate not more recent'),
|
|
('failed-file', 'c.mft', 0, 102, ['c.mft'], None, 0, 'manifest file hash mismatch'),
|
|
('retry', 'c.mft', 0, 102, ['c.mft'], None, 1, 'manifest filename changed'),
|
|
('replay-a', 'a.mft', 2**159-1, 100, ['a.mft'], None, 0, 'thisUpdate not more recent'),
|
|
('back-a', 'a.mft', 0, 103, ['a.mft'], None, 1, 'manifest filename changed'),
|
|
('multi-sia', 'b.mft', 0, 104, ['b.mft', 'a.mft'], None, 0, 'manifestNumber not higher'),
|
|
('multi-sia-new', 'b.mft', 1, 104, ['b.mft', 'a.mft'], None, 1, ''),
|
|
('location-mismatch', 'b.mft', 2, 105, ['b.mft'], ['moved.mft'], 0, 'ManifestSiaUriMismatch'),
|
|
('multiple-ee', 'b.mft', 2, 105, ['b.mft'], ['other.mft', 'b.mft', 'b.mft'], 1, ''),
|
|
('same-number', 'b.mft', 2, 106, ['b.mft'], None, 0, 'manifestNumber not higher'),
|
|
('same-time', 'b.mft', 3, 105, ['b.mft'], None, 0, 'thisUpdate not more recent'),
|
|
('final', 'b.mft', 3, 107, ['b.mft'], None, 1, ''),
|
|
]
|
|
work = fixture/'history-work'; work.mkdir()
|
|
def manifest(path, number, stamp, files, issuer, signing_key, repo, ca_uri, ee_names):
|
|
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
ee = g.sign_cert(x509.Name([g.common_name('History Manifest EE')]), issuer, key.public_key(), signing_key,
|
|
x509.random_serial_number(), False, key, signing_key,
|
|
ip_ext=g.ip_inherit(), as_ext=g.as_resources(None),
|
|
signed_object_uris=[repo+name for name in ee_names], crl_uri=repo+('root.crl' if repo==g.ROOT_REPO_URI else 'child.crl'), ca_issuer_uri=ca_uri)
|
|
content = g.seq(g.integer(number), g.generalized_time(g.VALID_FROM+timedelta(days=30,seconds=stamp)),
|
|
g.generalized_time(g.VALID_TO-timedelta(days=30)), g.oid(g.OID_SHA256),
|
|
g.seq(*(g.seq(g.tlv(0x16,name.encode('ascii')), g.bit_string(hashlib.sha256(data).digest())) for name,data in files)))
|
|
g.write_cms(content,g.OID_MANIFEST_ECONTENT,ee,key,path,work)
|
|
for index, (name, filename, number, stamp, ca_names, ee_names, vrps, error) in enumerate(specs, 1):
|
|
case = fixture/'history-cases'/name; repo=case/'repository'
|
|
shutil.copytree(original,repo)
|
|
for old in (repo/'child').glob('*.mft'): old.unlink()
|
|
new_child = g.sign_cert(child.subject,root.subject,child_key.public_key(),root_key,1000+index,True,child_key,root_key,
|
|
ip_ext=g.ip_resources([g.CHILD_PREFIX]),as_ext=g.as_resources([g.TEST_ASN]),
|
|
ca_repository=g.CHILD_REPO_URI, manifest_uris=[g.CHILD_REPO_URI+n for n in ca_names], notify_uri=g.RRDP_URI,
|
|
crl_uri=g.ROOT_REPO_URI+'root.crl', ca_issuer_uri=g.ROOT_REPO_URI+'child.cer')
|
|
(repo/'root/child.cer').write_bytes(new_child.public_bytes(Encoding.DER))
|
|
# Keep the child's AIA-bound copy consistent too.
|
|
(repo/'child/child.cer').write_bytes(new_child.public_bytes(Encoding.DER))
|
|
manifest(repo/'root/root.mft', index+10, index+200,
|
|
[(n,(repo/'root'/n).read_bytes()) for n in ('root.crl','child.cer')],root.subject,root_key,g.ROOT_REPO_URI,g.ROOT_REPO_URI+'ta.cer',['root.mft'])
|
|
manifest(repo/'child'/filename, number, stamp,
|
|
[(n,(repo/'child'/n).read_bytes()) for n in ('child.crl','valid.roa','valid.asa')],child.subject,child_key,g.CHILD_REPO_URI,g.CHILD_REPO_URI+'child.cer',ee_names or [filename])
|
|
if name=='failed-file': (repo/'child/valid.roa').write_bytes(b'corrupted after signing manifest')
|
|
# Each new publication uses an independent session and a complete snapshot.
|
|
session = f'{index:08d}-2222-4333-8444-555555555555'
|
|
paths = [(g.ROOT_REPO_URI+n,repo/'root'/n) for n in ('root.crl','child.cer','root.mft')]
|
|
paths += [(g.CHILD_REPO_URI+n,repo/'child'/n) for n in ('child.crl','valid.roa','valid.asa',filename)]
|
|
snap = (f'<snapshot xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="1">\n'
|
|
+''.join(g.publish(uri,path) for uri,path in paths)+'</snapshot>\n').encode()
|
|
http=case/'http/rrdp';http.mkdir(parents=True)
|
|
(http/'snapshot.xml').write_bytes(snap)
|
|
(http/'notification.xml').write_text(f'<notification xmlns="http://www.ripe.net/rpki/rrdp" version="1" session_id="{session}" serial="1"><snapshot uri="https://127.0.0.1:{port}/rrdp/snapshot.xml" hash="{hashlib.sha256(snap).hexdigest()}"/></notification>')
|
|
cases.append(dict(name=name,root=case,vrps=vrps,error=error,renamed=name in {'renamed','stale-rename','failed-file','retry','replay-a','back-a'}))
|
|
return cases
|
|
|
|
|
|
def run(binary: str, evidence: Path|None=None) -> dict:
|
|
proof={'transports':[]}
|
|
with tempfile.TemporaryDirectory(prefix='panda-manifest-history-') as tmp:
|
|
base=Path(tmp);fixture=base/'fixture';port=free_port()
|
|
run_checked([sys.executable,str(Path(__file__).with_name('generate_repository.py')),'--output',str(fixture),'--rrdp-host','127.0.0.1','--rrdp-port',str(port),'--rsync-host','127.0.0.1','--strict-names'])
|
|
cases=create_cases(fixture,port)
|
|
active=base/'active';active.symlink_to(cases[0]['root']/'http',target_is_directory=True)
|
|
requests=[]
|
|
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
def do_GET(self):
|
|
requests.append(self.path);super().do_GET()
|
|
def log_message(self,*args):pass
|
|
server=http.server.ThreadingHTTPServer(('127.0.0.1',port),functools.partial(Handler,directory=str(active)))
|
|
tls=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);tls.load_cert_chain(fixture/'certs/rrdp-server.pem',fixture/'certs/rrdp-server.key');server.socket=tls.wrap_socket(server.socket,server_side=True)
|
|
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
|
|
try:
|
|
for transport in ('rrdp-then-rsync','rsync-only'):
|
|
transport_root=base/transport;transport_root.mkdir();state=transport_root/'state'
|
|
wrapper=transport_root/'rsync.py';wrapper_log=transport_root/'rsync.log'
|
|
make_rsync_wrapper(wrapper,fixture,wrapper_log)
|
|
text=wrapper.read_text().replace(str(fixture/'cases/baseline-v1/repository'),str(base/'repository'))
|
|
wrapper.write_text(text);wrapper.chmod(0o755)
|
|
runs=[];initial_csv=None
|
|
for case in [cases[0],dict(cases[0],name='restart-identical'),*cases[1:],dict(cases[-1],name='restart-final')]:
|
|
if active.is_symlink():active.unlink()
|
|
active.symlink_to(case['root']/'http',target_is_directory=True)
|
|
repository=base/'repository'
|
|
if repository.is_symlink():repository.unlink()
|
|
repository.symlink_to(case['root']/'repository',target_is_directory=True)
|
|
out=transport_root/case['name'];config=transport_root/'validate.toml'
|
|
write_config(config,fixture=fixture,output=out,state=state,ccr=out/'result.ccr',wrapper=wrapper,mirror=transport_root/'mirror',sync_preference=transport,rrdp_mode='auto',validation_time='2026-06-01T00:00:00Z',resource_mode='rfc6487',scope='publication-point')
|
|
config_text=config.read_text()
|
|
# Exercise CCR-off and one object worker, then the worker pool path.
|
|
if case['name']=='restart-identical':
|
|
config_text='\n'.join(line for line in config_text.splitlines() if not line.startswith('ccr-out ='))+'\n'
|
|
config_text=config_text.replace('parallel-phase2-object-workers = 2','parallel-phase2-object-workers = 1')
|
|
if not case['vrps']:config_text=config_text.replace('require-complete = true','require-complete = false')
|
|
config.write_text(config_text)
|
|
start=len(requests)
|
|
result=subprocess.run([binary,'validate','--config',str(config)],text=True,capture_output=True,timeout=120)
|
|
summary=read_json(out/'summary.json')
|
|
report=(out/'report.json').read_text() if (out/'report.json').exists() else ''
|
|
combined=result.stdout+result.stderr+report
|
|
assert summary['vrps']==case['vrps'],(transport,case['name'],summary,combined[-5000:])
|
|
assert result.returncode==0,(case['name'],result.returncode,combined[-5000:])
|
|
if case['error']:assert case['error'] in combined,(case['name'],case['error'],combined[-5000:])
|
|
if case['renamed']:assert 'manifest filename changed' in combined,(case['name'],combined[-5000:])
|
|
if case['name']=='multi-sia-new':assert 'manifest filename changed' not in combined
|
|
if case['vrps']:
|
|
current=(out/'vrps.csv').read_bytes()
|
|
if initial_csv is None:initial_csv=current
|
|
assert current==initial_csv,(case['name'],'legal output changed')
|
|
assert summary['resultStatus']=='success',summary
|
|
else:assert summary['resultStatus']=='partial',summary
|
|
seen=requests[start:]
|
|
if transport=='rsync-only':assert not seen,seen
|
|
else:assert not wrapper_log.exists(),'unexpected rsync fallback'
|
|
runs.append(dict(name=case['name'],exit_code=result.returncode,status=summary['resultStatus'],vrps=summary['vrps'],requests=seen,expected_error=case['error']))
|
|
proof['transports'].append(dict(transport=transport,runs=runs))
|
|
finally:server.shutdown();server.server_close();thread.join(timeout=5)
|
|
if evidence:evidence.write_text(json.dumps(proof,indent=2)+'\n')
|
|
return proof
|
|
|
|
if __name__=='__main__':
|
|
p=argparse.ArgumentParser();p.add_argument('binary');p.add_argument('--evidence',type=Path);a=p.parse_args()
|
|
result=run(a.binary,a.evidence);print(json.dumps(result))
|