20261015_4 修复Manifest编号与改名抗重放校验
Some checks failed
ci / rust (push) Failing after 17s
ci / docker (push) Failing after 1m12s
ci / audit (push) Failing after 8s

This commit is contained in:
2026-10-16 11:16:20 +08:00
parent 385af349ae
commit 5f46837b61
34 changed files with 1346 additions and 149 deletions

View File

@@ -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:

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