feat(proto,pki,cli): in-band CRL push (closes last v2 limitation)

Server now pushes its signed CRL to each connecting client right after the
handshake; the client verifies the signature against the CA and applies the
revocation list to its verifier (and caches it on disk for restarts).
Removes the v1 "CRL distributed out-of-band" honest limitation.

Wire (multiplexed over existing PacketConnection, no trait change):
control envelope = MAGIC[4]=[0xAA,0xAA,0xC0,0x01] || kind(u8) || u32_be(len)
  || payload. IPv4/IPv6 start with 0x4X/0x6X, so 0xAA cannot collide; an old
peer just drops it as a junk packet in the TUN — back-compat preserved.

- aura-proto: ControlKind { CrlPush, CrlAck, Unknown }, encode/decode_control_
  envelope, CONTROL_ENVELOPE_MAGIC; 7 frame tests.
- aura-pki: CrlStore::{encode_signed, save_signed, decode_signed_verified,
  load_signed_verified} — ECDSA-P256/SHA-256 from the CA private key against
  a textual "CRL-Aura-v1" body + --SIGNATURE--; 7 signing tests. ring 0.17
  added crate-local (already in lockfile via rustls-webpki).
- aura-cli: crl_push module — server pushes via conn.send_packet on accept;
  client wraps the Arc<dyn PacketConnection> in AcceptPushedCrlConn which
  sniffs the magic in recv_packet, verifies the signature, updates the
  AuraCertVerifier, caches to disk. PkiSection gets ca_key, crl_push (default
  true), accept_pushed_crl (default true).
- 5 in_band_crl integration tests via mock PacketConnection.

Workspace: 235 tests passed (+28), clippy -D warnings clean, fmt clean. v2
COMPLETE — all 9 honest v1 limitations resolved (except sing-box, per user).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xah30
2026-05-27 12:35:16 +03:00
parent 8f0cf1f017
commit 35d94dee33
14 changed files with 1453 additions and 4 deletions
+163
View File
@@ -0,0 +1,163 @@
//! Tests for the v2 signed-CRL format ([`CrlStore::save_signed`] / [`CrlStore::load_signed_verified`]).
//!
//! Covers:
//! * happy-path round-trip (encode + decode + verify against the same CA),
//! * tampered body rejection (mutate any character in the id list),
//! * tampered signature rejection (flip a nibble in the hex signature),
//! * cross-CA rejection (decode against a different CA's public key fails),
//! * missing-marker rejection.
use std::path::PathBuf;
use aura_pki::{AuraCa, CrlStore};
use uuid::Uuid;
/// A unique temp file path so parallel tests do not collide.
fn temp_path(suffix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("aura-pki-test-{}-{suffix}", Uuid::new_v4()));
p
}
/// Helper: build a CA + a small CRL of two ids.
fn make_ca_and_crl() -> (AuraCa, String, CrlStore) {
let ca = AuraCa::generate("Aura Test CRL CA").unwrap();
let ca_cert_pem = ca.ca_cert_pem();
let mut crl = CrlStore::new();
crl.revoke("alice");
crl.revoke("deadbeef");
(ca, ca_cert_pem, crl)
}
#[test]
fn signed_crl_round_trip_verifies() {
// Borrow a CA + key from the in-memory AuraCa via save/load.
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let (ca, ca_cert_pem, crl) = make_ca_and_crl();
ca.save(&cert_path, &key_path).unwrap();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let crl_path = temp_path("revoked.crl");
crl.save_signed(&crl_path, &ca_cert_pem, &ca_key_pem)
.expect("save_signed succeeds");
let loaded =
CrlStore::load_signed_verified(&crl_path, &ca_cert_pem).expect("verification succeeds");
assert!(loaded.contains("alice"));
assert!(loaded.contains("deadbeef"));
assert!(!loaded.contains("bob"));
assert_eq!(loaded.len(), 2);
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
let _ = std::fs::remove_file(crl_path);
}
#[test]
fn tampered_body_fails_verification() {
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let (ca, ca_cert_pem, crl) = make_ca_and_crl();
ca.save(&cert_path, &key_path).unwrap();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let bytes = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap();
let mut text = String::from_utf8(bytes).unwrap();
// Tamper with an id: replace 'alice' with 'allice' (one byte more, sig over original body).
text = text.replacen("alice", "allice", 1);
let res = CrlStore::decode_signed_verified(text.as_bytes(), &ca_cert_pem);
assert!(res.is_err(), "tampered body must fail verification");
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
}
#[test]
fn tampered_signature_fails_verification() {
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let (ca, ca_cert_pem, crl) = make_ca_and_crl();
ca.save(&cert_path, &key_path).unwrap();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let bytes = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap();
let mut text = String::from_utf8(bytes).unwrap();
// Flip the last hex nibble of the signature.
let last_idx = text.rfind(|c: char| c.is_ascii_hexdigit()).unwrap();
let ch = text.as_bytes()[last_idx];
let new = if ch == b'0' { b'1' } else { b'0' };
unsafe {
text.as_bytes_mut()[last_idx] = new;
}
let res = CrlStore::decode_signed_verified(text.as_bytes(), &ca_cert_pem);
assert!(res.is_err(), "tampered signature must fail verification");
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
}
#[test]
fn signature_against_wrong_ca_fails() {
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let (ca, ca_cert_pem, crl) = make_ca_and_crl();
ca.save(&cert_path, &key_path).unwrap();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let bytes = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap();
// A different CA's anchor cannot verify a CRL signed by the original.
let rogue = AuraCa::generate("Rogue CA").unwrap();
let res = CrlStore::decode_signed_verified(&bytes, &rogue.ca_cert_pem());
assert!(res.is_err(), "wrong CA must fail verification");
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
}
#[test]
fn missing_marker_is_rejected() {
let (_, ca_cert_pem, _) = make_ca_and_crl();
let bogus = b"CRL-Aura-v1\nalice\nbob\nno-marker-here\n";
assert!(CrlStore::decode_signed_verified(bogus, &ca_cert_pem).is_err());
}
#[test]
fn unknown_header_is_rejected() {
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let (ca, ca_cert_pem, crl) = make_ca_and_crl();
ca.save(&cert_path, &key_path).unwrap();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let bytes = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap();
// Mutate the header line to something else and re-sign would be needed — but here we just
// check that the parser rejects an unknown header verbatim (signature also fails because we
// mutated the signed body, but the header check fires first).
let mut text = String::from_utf8(bytes).unwrap();
text = text.replacen("CRL-Aura-v1", "CRL-Aura-v9", 1);
let res = CrlStore::decode_signed_verified(text.as_bytes(), &ca_cert_pem);
assert!(res.is_err(), "unknown header must be rejected");
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
}
#[test]
fn empty_crl_round_trip() {
let cert_path = temp_path("ca.crt");
let key_path = temp_path("ca.key");
let ca = AuraCa::generate("Aura Test CRL CA").unwrap();
ca.save(&cert_path, &key_path).unwrap();
let ca_cert_pem = ca.ca_cert_pem();
let ca_key_pem = std::fs::read_to_string(&key_path).unwrap();
let crl = CrlStore::new();
let bytes = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap();
let loaded = CrlStore::decode_signed_verified(&bytes, &ca_cert_pem).unwrap();
assert!(loaded.is_empty(), "empty signed CRL round-trips as empty");
let _ = std::fs::remove_file(cert_path);
let _ = std::fs::remove_file(key_path);
}