20261015 实现RRDP失步检测与TA证书择优
Some checks failed
ci / rust (push) Failing after 1m42s
ci / docker (push) Failing after 50s
ci / audit (push) Failing after 8s

This commit is contained in:
2026-10-15 14:31:50 +08:00
parent 0fa0c940fe
commit 385af349ae
23 changed files with 989 additions and 92 deletions

View File

@@ -0,0 +1,9 @@
#[test]
fn persisted_rrdp_desynchronization_and_ta_tiebreaker_are_end_to_end() {
let status = std::process::Command::new("python3")
.arg("tests/support/test_security_recovery.py")
.arg(env!("CARGO_BIN_EXE_panda-rpki"))
.status()
.expect("start RRDP recovery and TA selection integration driver");
assert!(status.success(), "security recovery E2E failed: {status}");
}

View File

@@ -11,3 +11,17 @@ network request is included in this directory. The generator requires Python
`cryptography`, OpenSSL's `cms` command, and Python 3; CI installs those test
tools explicitly. Test output is created under a temporary directory and is
not part of a release artifact.
# Security recovery and TA selection
`cargo test --offline --test security_recovery_e2e` runs the real CLI against a
local HTTPS repository with persisted state. Across three processes it checks
initial snapshot, recovery after a delta-hash mutation at unchanged session/serial,
and a subsequent noop. Request assertions prove that no historical delta is
downloaded. A newer cached TA must survive a valid older download under
`require-fresh`, with a successful result and no cache-fallback count.
To preserve the per-step request and summary evidence:
```sh
python3 tests/support/test_security_recovery.py target/debug/panda-rpki --proof /tmp/security-recovery.json
```

View File

@@ -545,6 +545,29 @@ def make_https_certificates(output: Path) -> None:
write_key(output / "certs/rrdp-server.key", server_key)
def make_ta_variants(output: Path, name: x509.Name, key, foreign_key) -> None:
"""Real self-signed versions of one TAL-bound key, for tiebreaker tests."""
variants = {
"older": (datetime(2025, 1, 1, tzinfo=UTC), VALID_TO),
"newer": (datetime(2026, 5, 1, tzinfo=UTC), VALID_TO),
"shorter": (VALID_FROM, datetime(2026, 12, 31, tzinfo=UTC)),
"longer": (VALID_FROM, datetime(2030, 1, 1, tzinfo=UTC)),
"equal-period": (VALID_FROM, VALID_TO),
"expired": (datetime(2025, 1, 1, tzinfo=UTC), datetime(2025, 12, 31, tzinfo=UTC)),
"future": (datetime(2027, 1, 1, tzinfo=UTC), datetime(2028, 1, 1, tzinfo=UTC)),
"wrong-key": (VALID_FROM, VALID_TO),
}
for serial, (variant, (start, end)) in enumerate(variants.items(), 0x14700100):
selected_key = foreign_key if variant == "wrong-key" else key
certificate = sign_cert(
name, name, selected_key.public_key(), selected_key, serial, True, selected_key,
ip_ext=ip_resources([CHILD_PREFIX]), as_ext=as_resources([TEST_ASN]),
ca_repository=ROOT_REPO_URI, manifest_uri=ROOT_MFT_URI, notify_uri=RRDP_URI,
not_before=start, not_after=end,
)
write(output / f"ta-variants/{variant}.cer", certificate.public_bytes(Encoding.DER))
def main() -> None:
global RRDP_HOST, FOREIGN_RRDP_HOST, RRDP_PORT, RSYNC_HOST, RSYNC_PORT
global TAL_URI, TAL_HTTPS_URI, RRDP_URI, STRICT_NAMES
@@ -638,6 +661,7 @@ def main() -> None:
).encode()
write(output / "tal/custom.tal", tal)
write(output / "ta/custom-ta.cer", root_cert.public_bytes(Encoding.DER))
make_ta_variants(output, root_name, root_key, child_key)
with tempfile.TemporaryDirectory(prefix="custom-rpki-cms-") as temp:
work = Path(temp)
common = output / ".common"

View File

@@ -0,0 +1,151 @@
"""Real CLI, HTTPS and persisted state for RFC 9697 and TA tiebreaker -06."""
from __future__ import annotations
import argparse
import functools
import http.server
import json
from pathlib import Path
import shutil
import ssl
import sys
import tempfile
import threading
from test_public_config import (
assert_successful_output,
free_port,
make_rsync_wrapper,
read_json,
run_checked,
write_config,
)
def run(binary: str) -> dict:
support = Path(__file__).resolve().parent
with tempfile.TemporaryDirectory(prefix="panda-rpki-security-") as tmp:
base = Path(tmp)
fixture = base / "fixture"
port = free_port()
run_checked([
sys.executable, str(support / "generate_repository.py"),
"--rrdp-host", "127.0.0.1", "--rrdp-port", str(port),
"--rsync-host", "127.0.0.1", "--strict-names", "--output", str(fixture),
])
active = base / "active"
shutil.copytree(fixture / "cases/baseline-v1/http", active)
# Prefer real HTTPS for automatic TA retrieval. Retain the rsync URI:
# the validation core uses it as the key for its offline TA projection.
tal = fixture / "tal/custom.tal"
lines = tal.read_text().splitlines()
tal.write_text("\n".join(
[line for line in lines if line.startswith("https://")]
+ [line for line in lines if line.startswith("rsync://")]
+ [line for line in lines if not line.startswith(("https://", "rsync://"))]
) + "\n", encoding="utf-8")
served_ta = active / "ta/custom-ta.cer"
served_ta.write_bytes((fixture / "ta-variants/newer.cer").read_bytes())
notification = active / "rrdp/notification.xml"
original = notification.read_text()
def advertise_hash(value: str) -> None:
reference = (
f'<delta serial="1" uri="https://127.0.0.1:{port}/rrdp/delta-1.xml" '
f'hash="{value * 32}"/>'
)
notification.write_text(original.replace("</notification>", reference + "</notification>"))
advertise_hash("11")
requests: list[str] = []
class Handler(http.server.SimpleHTTPRequestHandler):
def do_GET(self) -> None:
requests.append(self.path)
super().do_GET()
def log_message(self, format: str, *args) -> None:
pass
server = http.server.ThreadingHTTPServer(
("127.0.0.1", port), functools.partial(Handler, directory=str(active))
)
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain(fixture / "certs/rrdp-server.pem", fixture / "certs/rrdp-server.key")
server.socket = context.wrap_socket(server.socket, server_side=True)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
output, state = base / "output", base / "state"
wrapper, wrapper_log = base / "rsync.py", base / "rsync.log"
make_rsync_wrapper(wrapper, fixture, wrapper_log)
config = base / "validate.toml"
write_config(
config, fixture=fixture, output=output, state=state,
ccr=output / "result.ccr", wrapper=wrapper, mirror=base / "mirror",
sync_preference="rrdp-then-rsync", rrdp_mode="auto",
validation_time="2026-06-01T00:00:00Z",
resource_mode="rfc6487", scope="publication-point",
)
text = "\n".join(line for line in config.read_text().splitlines() if not line.startswith("ta = "))
text = text.replace("[strict]", 'ta-refresh-policy = "require-fresh"\n\n[strict]')
config.write_text(text + "\n", encoding="utf-8")
proof = {"steps": []}
def validate(name: str) -> tuple[dict, str, list[str]]:
start = len(requests)
result = run_checked([binary, "validate", "--config", str(config)])
assert_successful_output(output, "2026-06-01T00:00:00Z")
summary = read_json(output / "summary.json")
seen = requests[start:]
assert summary["taCacheFallbackCount"] == 0, summary
assert not any("delta-" in uri for uri in seen), seen
assert not wrapper_log.exists(), "unexpected rsync fallback"
proof["steps"].append({
"name": name, "requests": seen, "vrps": summary["vrps"],
"resultStatus": summary["resultStatus"],
"taCacheFallbackCount": summary["taCacheFallbackCount"],
"trustAnchors": summary["trustAnchors"],
})
return summary, result.stderr, seen
summary, _, seen = validate("initial_snapshot_newer_ta")
assert "/rrdp/snapshot.xml" in seen, seen
assert summary["trustAnchors"][0]["source"] == "download", summary
cache = next(base.joinpath("state.ta-cache").glob("*.json"))
cache_before = cache.read_bytes()
# A new process sees a mutated historical delta hash at the same
# session/serial, and a valid but older TA with the identical key.
advertise_hash("33")
served_ta.write_bytes((fixture / "ta/custom-ta.cer").read_bytes())
summary, stderr, seen = validate("restarted_hash_mutation_and_older_ta")
assert "/rrdp/snapshot.xml" in seen, seen
assert "rrdp_session_desynchronized" in stderr, stderr
anchor = summary["trustAnchors"][0]
assert anchor["source"] == "cache" and not anchor["refreshFailed"], anchor
assert anchor["selectionReason"] == "cached_newer_not_before", anchor
assert cache.read_bytes() == cache_before, "older TA replaced the preferred cache"
_, stderr, seen = validate("restarted_noop_after_recovery")
assert "/rrdp/snapshot.xml" not in seen, seen
assert "rrdp_session_desynchronized" not in stderr, stderr
assert cache.read_bytes() == cache_before
return proof
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("binary")
parser.add_argument("--proof", type=Path)
args = parser.parse_args()
proof = run(args.binary)
if args.proof:
args.proof.parent.mkdir(parents=True, exist_ok=True)
args.proof.write_text(json.dumps(proof, indent=2) + "\n", encoding="utf-8")
print(json.dumps(proof, indent=2))