Files
panda-rpki-oss/tests/support/test_manifest_history.py
yuyr 5f46837b61
Some checks failed
ci / rust (push) Failing after 17s
ci / docker (push) Failing after 1m12s
ci / audit (push) Failing after 8s
20261015_4 修复Manifest编号与改名抗重放校验
2026-10-16 11:16:20 +08:00

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))