20261015_4 修复Manifest编号与改名抗重放校验
This commit is contained in:
9
tests/manifest_history_e2e.rs
Normal file
9
tests/manifest_history_e2e.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
#[test]
|
||||
fn manifest_rename_and_location_binding_survive_real_cli_restarts() {
|
||||
let status = std::process::Command::new("python3")
|
||||
.arg("tests/support/test_manifest_history.py")
|
||||
.arg(env!("CARGO_BIN_EXE_panda-rpki"))
|
||||
.status()
|
||||
.expect("start manifest history CLI driver");
|
||||
assert!(status.success(), "manifest history E2E failed: {status}");
|
||||
}
|
||||
@@ -191,8 +191,10 @@ def sign_cert(
|
||||
as_ext: bytes | None = None,
|
||||
ca_repository: str | None = None,
|
||||
manifest_uri: str | None = None,
|
||||
manifest_uris: list[str] | None = None,
|
||||
notify_uri: str | None = None,
|
||||
signed_object_uri: str | None = None,
|
||||
signed_object_uris: list[str] | None = None,
|
||||
crl_uri: str | None = None,
|
||||
ca_issuer_uri: str | None = None,
|
||||
not_before: datetime = VALID_FROM,
|
||||
@@ -240,10 +242,14 @@ def sign_cert(
|
||||
sia_entries.append((SIA_CA_REPOSITORY, ca_repository))
|
||||
if manifest_uri is not None:
|
||||
sia_entries.append((SIA_RPKI_MANIFEST, manifest_uri))
|
||||
for uri in manifest_uris or []:
|
||||
sia_entries.append((SIA_RPKI_MANIFEST, uri))
|
||||
if notify_uri is not None:
|
||||
sia_entries.append((SIA_RPKI_NOTIFY, notify_uri))
|
||||
if signed_object_uri is not None:
|
||||
sia_entries.append((SIA_SIGNED_OBJECT, signed_object_uri))
|
||||
for uri in signed_object_uris or []:
|
||||
sia_entries.append((SIA_SIGNED_OBJECT, uri))
|
||||
if sia_entries:
|
||||
builder = builder.add_extension(x509.UnrecognizedExtension(OID_SIA, sia(sia_entries)), critical=False)
|
||||
if ip_ext is not None:
|
||||
|
||||
148
tests/support/test_manifest_history.py
Normal file
148
tests/support/test_manifest_history.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user