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,310 @@
|
||||
//! End-to-end test of the v2 in-band CRL push flow at the [`PacketConnection`] layer.
|
||||
//!
|
||||
//! We avoid spinning up a real transport (which needs root + privileged sockets) and instead drive
|
||||
//! the server-side helper `push_crl_if_configured` against an in-memory mock `PacketConnection`,
|
||||
//! then feed the bytes the server "sent" into a client-side `AcceptPushedCrlConn` wrapper and
|
||||
//! check that:
|
||||
//!
|
||||
//! * the wrapper consumes the envelope (does NOT deliver it to the TUN-bound `recv_packet`),
|
||||
//! * the wrapper verifies the signature against the CA and applies the CRL,
|
||||
//! * the wrapper persists the parsed CRL to the configured cache path,
|
||||
//! * a real IP packet that arrives *after* the envelope is delivered verbatim to the caller.
|
||||
//!
|
||||
//! The path runs entirely on mpsc channels, so it exercises the wrapping logic without any
|
||||
//! crypto/transport setup.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use aura_cli::crl_push::{push_crl_if_configured, AcceptPushedCrlConn};
|
||||
use aura_pki::{AuraCa, CrlStore};
|
||||
use aura_proto::PacketConnection;
|
||||
use tokio::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Mock connection with two roles in this test:
|
||||
/// * **server side**: the server's `push_crl_if_configured` calls `send_packet` on its `Arc<dyn
|
||||
/// PacketConnection>`. We capture the bytes here.
|
||||
/// * **client side**: the client wraps this same struct (re-instantiated with the captured bytes
|
||||
/// in `to_recv`) and calls `recv_packet`.
|
||||
struct MockConn {
|
||||
to_recv: Mutex<VecDeque<Vec<u8>>>,
|
||||
sent: Mutex<Vec<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MockConn {
|
||||
fn new(packets: impl IntoIterator<Item = Vec<u8>>) -> Self {
|
||||
Self {
|
||||
to_recv: Mutex::new(packets.into_iter().collect()),
|
||||
sent: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_sent(&self) -> Vec<Vec<u8>> {
|
||||
std::mem::take(&mut *self.sent.lock().await)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PacketConnection for MockConn {
|
||||
async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> {
|
||||
self.sent.lock().await.push(packet.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
async fn recv_packet(&self) -> anyhow::Result<Vec<u8>> {
|
||||
self.to_recv
|
||||
.lock()
|
||||
.await
|
||||
.pop_front()
|
||||
.ok_or_else(|| anyhow::anyhow!("mock conn drained"))
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_path(suffix: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("aura-cli-in_band_crl-{}-{suffix}", Uuid::new_v4()));
|
||||
p
|
||||
}
|
||||
|
||||
/// Happy path: server pushes a signed CRL of `{"alice"}`; client decodes + applies + persists.
|
||||
#[tokio::test]
|
||||
async fn server_push_is_applied_on_the_client() {
|
||||
// 1. CA + on-disk CA paths (save/load to get the key PEM string).
|
||||
let ca = AuraCa::generate("Aura CRL IT").unwrap();
|
||||
let ca_cert_pem = ca.ca_cert_pem();
|
||||
let ca_cert_path = temp_path("ca.crt");
|
||||
let ca_key_path = temp_path("ca.key");
|
||||
ca.save(&ca_cert_path, &ca_key_path).unwrap();
|
||||
|
||||
// 2. Server-side CRL file (unsigned v1 format).
|
||||
let crl_path = temp_path("revoked.crl");
|
||||
let mut crl = CrlStore::new();
|
||||
crl.revoke("alice");
|
||||
crl.revoke("deadbeef");
|
||||
crl.save(&crl_path).unwrap();
|
||||
|
||||
// 3. Server-side mock conn (its `sent` slot is what the wire would carry).
|
||||
let server_mock = Arc::new(MockConn::new([]));
|
||||
let server_conn: Arc<dyn PacketConnection> = server_mock.clone();
|
||||
|
||||
// 4. Drive the server-side helper.
|
||||
let pushed = push_crl_if_configured(
|
||||
true,
|
||||
Some(crl_path.to_str().unwrap()),
|
||||
&ca_cert_pem,
|
||||
Some(ca_key_path.to_str().unwrap()),
|
||||
&server_conn,
|
||||
Some("test-peer"),
|
||||
)
|
||||
.await
|
||||
.expect("push_crl_if_configured returns Ok");
|
||||
assert!(pushed, "server should report a successful push");
|
||||
|
||||
// 5. Capture the envelope the server "sent" and inject it on the client side.
|
||||
let envelopes = server_mock.drain_sent().await;
|
||||
assert_eq!(envelopes.len(), 1, "exactly one envelope was sent");
|
||||
let envelope = envelopes.into_iter().next().unwrap();
|
||||
assert_eq!(
|
||||
&envelope[..4],
|
||||
&[0xAA, 0xAA, 0xC0, 0x01],
|
||||
"envelope starts with the CRL magic prefix"
|
||||
);
|
||||
|
||||
// 6. Build the client-side mock, feeding the envelope first and a real IPv4 packet second.
|
||||
let real_ipv4 = vec![0x45u8, 0x00, 0x00, 0x14, 0xab, 0xcd];
|
||||
let client_inner: Arc<dyn PacketConnection> =
|
||||
Arc::new(MockConn::new([envelope, real_ipv4.clone()]));
|
||||
let cache_path = temp_path("client_revoked.crl");
|
||||
let wrap = AcceptPushedCrlConn::new(
|
||||
client_inner,
|
||||
ca_cert_pem.clone(),
|
||||
Some(cache_path.clone()),
|
||||
true, // accept_pushed_crl = true
|
||||
);
|
||||
|
||||
// 7. Client's first recv_packet consumes the envelope (not the IPv4 packet) and applies the
|
||||
// CRL. The next bytes pulled from `recv_packet` are the real IPv4 packet.
|
||||
let pkt = wrap.recv_packet().await.unwrap();
|
||||
assert_eq!(pkt, real_ipv4, "real packet delivered after envelope");
|
||||
|
||||
// 8. Verify the CRL was applied + persisted.
|
||||
let applied = wrap.last_applied.read().await.clone();
|
||||
let applied = applied.expect("CRL should have been applied");
|
||||
assert!(applied.contains("alice"));
|
||||
assert!(applied.contains("deadbeef"));
|
||||
assert_eq!(applied.len(), 2);
|
||||
|
||||
let from_disk = CrlStore::load(&cache_path).unwrap();
|
||||
assert!(from_disk.contains("alice"));
|
||||
assert!(from_disk.contains("deadbeef"));
|
||||
|
||||
let _ = std::fs::remove_file(ca_cert_path);
|
||||
let _ = std::fs::remove_file(ca_key_path);
|
||||
let _ = std::fs::remove_file(crl_path);
|
||||
let _ = std::fs::remove_file(cache_path);
|
||||
}
|
||||
|
||||
/// When `crl_push_enabled = false`, the server never sends an envelope and the client recv path
|
||||
/// continues to behave exactly as in v1.
|
||||
#[tokio::test]
|
||||
async fn server_does_not_push_when_disabled() {
|
||||
let ca = AuraCa::generate("Aura CRL IT").unwrap();
|
||||
let ca_cert_path = temp_path("ca.crt");
|
||||
let ca_key_path = temp_path("ca.key");
|
||||
ca.save(&ca_cert_path, &ca_key_path).unwrap();
|
||||
|
||||
let crl_path = temp_path("revoked.crl");
|
||||
let mut crl = CrlStore::new();
|
||||
crl.revoke("alice");
|
||||
crl.save(&crl_path).unwrap();
|
||||
|
||||
let server_mock = Arc::new(MockConn::new([]));
|
||||
let server_conn: Arc<dyn PacketConnection> = server_mock.clone();
|
||||
let pushed = push_crl_if_configured(
|
||||
false, // disabled
|
||||
Some(crl_path.to_str().unwrap()),
|
||||
&ca.ca_cert_pem(),
|
||||
Some(ca_key_path.to_str().unwrap()),
|
||||
&server_conn,
|
||||
Some("peer"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!pushed, "disabled server should not push");
|
||||
assert!(
|
||||
server_mock.drain_sent().await.is_empty(),
|
||||
"no bytes should have been sent"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(ca_cert_path);
|
||||
let _ = std::fs::remove_file(ca_key_path);
|
||||
let _ = std::fs::remove_file(crl_path);
|
||||
}
|
||||
|
||||
/// If the CRL file does not exist (no revocations yet), the helper silently skips.
|
||||
#[tokio::test]
|
||||
async fn server_skips_when_crl_file_missing() {
|
||||
let ca = AuraCa::generate("Aura").unwrap();
|
||||
let ca_cert_path = temp_path("ca.crt");
|
||||
let ca_key_path = temp_path("ca.key");
|
||||
ca.save(&ca_cert_path, &ca_key_path).unwrap();
|
||||
let nonexistent = temp_path("nope.crl");
|
||||
|
||||
let server_mock = Arc::new(MockConn::new([]));
|
||||
let server_conn: Arc<dyn PacketConnection> = server_mock.clone();
|
||||
let pushed = push_crl_if_configured(
|
||||
true,
|
||||
Some(nonexistent.to_str().unwrap()),
|
||||
&ca.ca_cert_pem(),
|
||||
Some(ca_key_path.to_str().unwrap()),
|
||||
&server_conn,
|
||||
Some("peer"),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!pushed, "missing CRL should not push");
|
||||
assert!(server_mock.drain_sent().await.is_empty());
|
||||
|
||||
let _ = std::fs::remove_file(ca_cert_path);
|
||||
let _ = std::fs::remove_file(ca_key_path);
|
||||
}
|
||||
|
||||
/// If the server pushes a CRL signed by a different CA, the client refuses to apply it. The real
|
||||
/// packet that follows the envelope is still delivered (the wrapper just drops the bad envelope
|
||||
/// and keeps looping).
|
||||
#[tokio::test]
|
||||
async fn client_rejects_push_signed_by_wrong_ca() {
|
||||
let real = AuraCa::generate("Real").unwrap();
|
||||
let rogue = AuraCa::generate("Rogue").unwrap();
|
||||
let rogue_cert_path = temp_path("rogue.crt");
|
||||
let rogue_key_path = temp_path("rogue.key");
|
||||
rogue.save(&rogue_cert_path, &rogue_key_path).unwrap();
|
||||
|
||||
let crl_path = temp_path("rogue.crl");
|
||||
let mut crl = CrlStore::new();
|
||||
crl.revoke("alice");
|
||||
crl.save(&crl_path).unwrap();
|
||||
|
||||
// Server "pushes" using the rogue CA.
|
||||
let server_mock = Arc::new(MockConn::new([]));
|
||||
let server_conn: Arc<dyn PacketConnection> = server_mock.clone();
|
||||
let pushed = push_crl_if_configured(
|
||||
true,
|
||||
Some(crl_path.to_str().unwrap()),
|
||||
&rogue.ca_cert_pem(),
|
||||
Some(rogue_key_path.to_str().unwrap()),
|
||||
&server_conn,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(pushed);
|
||||
let envelope = server_mock.drain_sent().await.into_iter().next().unwrap();
|
||||
|
||||
// Client trusts only `real`; the rogue's signature must fail verification.
|
||||
let real_ipv4 = vec![0x45u8, 0x00, 0x00, 0x14];
|
||||
let client_inner: Arc<dyn PacketConnection> =
|
||||
Arc::new(MockConn::new([envelope, real_ipv4.clone()]));
|
||||
let wrap = AcceptPushedCrlConn::new(client_inner, real.ca_cert_pem(), None, true);
|
||||
let pkt = wrap.recv_packet().await.unwrap();
|
||||
assert_eq!(pkt, real_ipv4);
|
||||
assert!(
|
||||
wrap.last_applied.read().await.is_none(),
|
||||
"rogue-signed CRL must not be applied"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(rogue_cert_path);
|
||||
let _ = std::fs::remove_file(rogue_key_path);
|
||||
let _ = std::fs::remove_file(crl_path);
|
||||
}
|
||||
|
||||
/// `accept_pushed_crl = false` makes the client drop pushes (the wrapper still strips the envelope
|
||||
/// so the TUN never sees the magic bytes).
|
||||
#[tokio::test]
|
||||
async fn client_drops_push_when_disabled() {
|
||||
let ca = AuraCa::generate("Aura").unwrap();
|
||||
let ca_cert_path = temp_path("ca.crt");
|
||||
let ca_key_path = temp_path("ca.key");
|
||||
ca.save(&ca_cert_path, &ca_key_path).unwrap();
|
||||
|
||||
let crl_path = temp_path("revoked.crl");
|
||||
let mut crl = CrlStore::new();
|
||||
crl.revoke("alice");
|
||||
crl.save(&crl_path).unwrap();
|
||||
|
||||
let server_mock = Arc::new(MockConn::new([]));
|
||||
let server_conn: Arc<dyn PacketConnection> = server_mock.clone();
|
||||
let _ = push_crl_if_configured(
|
||||
true,
|
||||
Some(crl_path.to_str().unwrap()),
|
||||
&ca.ca_cert_pem(),
|
||||
Some(ca_key_path.to_str().unwrap()),
|
||||
&server_conn,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let envelope = server_mock.drain_sent().await.into_iter().next().unwrap();
|
||||
|
||||
let real_ipv4 = vec![0x45u8, 0x00, 0x00, 0x14];
|
||||
let client_inner: Arc<dyn PacketConnection> =
|
||||
Arc::new(MockConn::new([envelope, real_ipv4.clone()]));
|
||||
let wrap = AcceptPushedCrlConn::new(
|
||||
client_inner,
|
||||
ca.ca_cert_pem(),
|
||||
None,
|
||||
false, /* accept */
|
||||
);
|
||||
let pkt = wrap.recv_packet().await.unwrap();
|
||||
assert_eq!(pkt, real_ipv4);
|
||||
assert!(
|
||||
wrap.last_applied.read().await.is_none(),
|
||||
"disabled accept must not apply the CRL"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(ca_cert_path);
|
||||
let _ = std::fs::remove_file(ca_key_path);
|
||||
let _ = std::fs::remove_file(crl_path);
|
||||
}
|
||||
Reference in New Issue
Block a user