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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user