//! v2 in-band CRL push: server-to-client distribution of the revocation list right after a //! successful handshake. //! //! The wire path reuses the existing post-handshake [`aura_proto::PacketConnection`] without //! changing the trait or any transport. Control messages are multiplexed alongside real IP packets //! using the 4-byte magic prefix described in [`aura_proto::CONTROL_ENVELOPE_MAGIC`]: a real //! IPv4/IPv6 packet starts with `0x4X` or `0x6X` so a `0xAA`-prefixed envelope can never collide. //! //! ## Server side ([`push_crl_if_configured`]) //! //! On each accepted connection, if `[pki] crl_push` is `true` and a CRL file + CA key are //! configured, the server reads the plain CRL, signs it with the CA key, wraps it in a //! [`aura_proto::ControlKind::CrlPush`] envelope, and `send_packet`s it to the client. Failures //! are non-fatal — they log a warning and the connection proceeds (so a missing CRL file or a //! stale signing key never tears down a freshly authenticated client). //! //! ## Client side ([`AcceptPushedCrlConn`]) //! //! The client wraps the raw `Arc` in [`AcceptPushedCrlConn`] before handing //! it to the [`aura_tunnel::AuraRouter`]. Every `recv_packet` call is sniffed: if the bytes start //! with the magic, the envelope is decoded, the signed CRL is verified against the CA, the CRL is //! applied to the live verifier (currently informational on the client — the verifier exists per //! handshake; the cached file is what matters for the next dial), and `recv_packet` keeps looping //! for the next packet. Any envelope that fails to verify is dropped with a warning. //! //! Back-compat: a peer that does not know about CRL pushes (old client) will see a packet whose //! first byte is `0xAA` and forward it to its TUN, which immediately rejects it as an invalid IP //! packet (top nibble `0xA` is not a valid IP version). The session stays alive. use std::path::{Path, PathBuf}; use std::sync::Arc; use aura_pki::CrlStore; use aura_proto::{decode_control_envelope, encode_control_envelope, ControlKind, PacketConnection}; use tokio::sync::RwLock; use crate::config::expand_tilde; /// Build the bytes the server should send (CRL header + signed body, wrapped in a control /// envelope), or `Ok(None)` if `[pki] crl_push` is disabled / the CRL file is missing / the CA /// signing key is unavailable. /// /// The CRL file at `crl_path` is taken **verbatim** (the unsigned v1 format: one id per line). It /// is signed in-memory with the CA key at `ca_key_pem` and the resulting `CRL-Aura-v1` body + /// `--SIGNATURE--` block is what travels on the wire. pub fn build_push_envelope( crl_path: &Path, ca_cert_pem: &str, ca_key_pem: &str, ) -> anyhow::Result> { let crl = CrlStore::load(crl_path)?; let signed = crl.encode_signed(ca_cert_pem, ca_key_pem)?; Ok(encode_control_envelope(ControlKind::CrlPush, &signed)) } /// Send `envelope_bytes` to the peer via `conn.send_packet`. Returns the underlying transport /// error if the send fails. pub async fn send_push( conn: &Arc, envelope_bytes: &[u8], ) -> anyhow::Result<()> { conn.send_packet(envelope_bytes).await } /// Convenience: resolve the configured CRL file + CA key paths and push the CRL on `conn`. /// /// Every step is best-effort: missing paths, unreadable files, and signing failures are logged at /// `warn` and converted to `Ok(false)` so the accept loop keeps serving the client. Returns /// `Ok(true)` iff the envelope was successfully transmitted, `Ok(false)` otherwise. pub async fn push_crl_if_configured( crl_push_enabled: bool, crl_path: Option<&str>, ca_cert_pem: &str, ca_key_path: Option<&str>, conn: &Arc, peer: Option<&str>, ) -> anyhow::Result { if !crl_push_enabled { return Ok(false); } let Some(crl_path) = crl_path else { tracing::debug!( peer = ?peer, "no [pki] crl configured; skipping in-band CRL push" ); return Ok(false); }; let Some(ca_key_path) = ca_key_path else { tracing::warn!( peer = ?peer, "[pki] crl_push = true but [pki] ca_key is unset; cannot sign — skipping" ); return Ok(false); }; let crl_path: PathBuf = expand_tilde(crl_path); if !crl_path.exists() { tracing::debug!( peer = ?peer, path = %crl_path.display(), "CRL file does not exist; skipping in-band CRL push (no revoked clients yet)" ); return Ok(false); } let ca_key_path = expand_tilde(ca_key_path); let ca_key_pem = match std::fs::read_to_string(&ca_key_path) { Ok(p) => p, Err(e) => { tracing::warn!( peer = ?peer, path = %ca_key_path.display(), error = %e, "failed to read CA signing key; skipping in-band CRL push" ); return Ok(false); } }; let envelope = match build_push_envelope(&crl_path, ca_cert_pem, &ca_key_pem) { Ok(v) => v, Err(e) => { tracing::warn!( peer = ?peer, error = %e, "failed to build signed CRL envelope; skipping in-band CRL push" ); return Ok(false); } }; if let Err(e) = send_push(conn, &envelope).await { tracing::warn!( peer = ?peer, error = %e, "failed to send CRL envelope; client may be racing close" ); return Ok(false); } tracing::info!( peer = ?peer, bytes = envelope.len(), "in-band CRL pushed to client" ); Ok(true) } /// Client-side adapter that intercepts CRL-push control envelopes coming over `inner` and applies /// them to a live `verifier` + optional on-disk cache. /// /// Wrap an `Arc` returned by [`aura_transport::dial`] before passing it to /// [`aura_tunnel::AuraRouter`]. Every `recv_packet` call is sniffed: control envelopes are /// consumed and never reach the TUN; ordinary IP packets pass through unchanged. pub struct AcceptPushedCrlConn { inner: Arc, /// CA cert PEM the client trusts — used to verify the pushed CRL's signature. ca_cert_pem: String, /// Optional on-disk cache path: every successfully verified CRL is written here so the next /// startup can apply it via [`AuraCertVerifier::set_revoked`](aura_pki::AuraCertVerifier::set_revoked) /// without depending on the server pushing again. cache_path: Option, /// When `false`, the wrapper still strips control envelopes but does not apply or cache them /// (matches the v1 behaviour for operators who explicitly opt out). accept: bool, /// Last applied CRL — exposed for tests / inspection. The live `AuraCertVerifier` lives inside /// the existing handshake, so we mirror the parsed CrlStore here instead of mutating it. pub last_applied: Arc>>, } impl AcceptPushedCrlConn { /// Wrap `inner` so CRL pushes from the server are decoded and stripped. /// /// `cache_path` (typically `[pki] crl` on the client) receives the **plain** unsigned CRL on a /// successful apply so the file format stays compatible with the operator-side `aura pki /// revoke` flow. pub fn new( inner: Arc, ca_cert_pem: String, cache_path: Option, accept: bool, ) -> Self { Self { inner, ca_cert_pem, cache_path, accept, last_applied: Arc::new(RwLock::new(None)), } } /// Shared handle to the most recently applied CRL (mostly for tests). pub fn last_applied(&self) -> Arc>> { Arc::clone(&self.last_applied) } /// Process a control envelope buffer extracted from a `recv_packet` call. Returns `Ok(())` so /// errors do not tear the session down — they only log. async fn handle_control(&self, kind: ControlKind, payload: Vec) { match kind { ControlKind::CrlPush => { if !self.accept { tracing::debug!("accept_pushed_crl = false; dropping incoming CRL push"); return; } match CrlStore::decode_signed_verified(&payload, &self.ca_cert_pem) { Ok(crl) => { let count = crl.len(); if let Some(path) = &self.cache_path { if let Err(e) = persist_crl(&crl, path) { tracing::warn!( path = %path.display(), error = %e, "applied pushed CRL but failed to persist to disk" ); } } *self.last_applied.write().await = Some(crl); tracing::info!(entries = count, "CRL applied from server push (in-band)"); } Err(e) => { tracing::warn!( error = %e, "received CRL push that failed verification; dropping" ); } } } ControlKind::CrlAck => { tracing::debug!("server CRL ack received (unexpected — client does not push CRLs)"); } // v3.1 circuit-setup envelopes (ExtendBridge / CircuitReady / CircuitFailed) are only // meaningful during multi-hop dial (see [`crate::circuit`]). By the time this wrapper // sees a connection the circuit (if any) is already established, so any late envelopes // are a no-op here. ControlKind::ExtendBridge | ControlKind::CircuitReady | ControlKind::CircuitFailed => { tracing::debug!( kind = ?kind, "unexpected circuit-setup control envelope on established connection; ignoring" ); } ControlKind::Unknown(b) => { tracing::debug!(kind = b, "unknown control envelope kind; ignoring"); } } } } /// Write the plain (unsigned) CRL to `path` so the next client startup can apply it via /// [`CrlStore::load`]. fn persist_crl(crl: &CrlStore, path: &Path) -> anyhow::Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent)?; } } crl.save(path) } #[async_trait::async_trait] impl PacketConnection for AcceptPushedCrlConn { async fn send_packet(&self, packet: &[u8]) -> anyhow::Result<()> { // Client never sends control envelopes; pass through verbatim. self.inner.send_packet(packet).await } async fn recv_packet(&self) -> anyhow::Result> { // Loop until we find a real IP packet. Control envelopes are stripped, applied, and // skipped — the underlying transport keeps blocking for the next datagram on its own. loop { let pkt = self.inner.recv_packet().await?; match decode_control_envelope(&pkt) { Ok(Some((kind, payload))) => { self.handle_control(kind, payload).await; // Continue the loop to deliver the *next* real packet to the caller. continue; } Ok(None) => return Ok(pkt), Err(e) => { // Malformed envelope (claims magic but truncated). Drop it (do not pass to // TUN — its first byte is the magic and the TUN would reject it anyway) and // keep looping for the next packet. tracing::warn!(error = %e, "malformed control envelope; dropping"); continue; } } } } } #[cfg(test)] mod tests { use super::*; use std::collections::VecDeque; use aura_pki::AuraCa; use tokio::sync::Mutex; /// In-memory mock PacketConnection where `recv_packet` drains a FIFO of pre-loaded buffers and /// `send_packet` appends to a Vec we can inspect. struct MockConn { to_recv: Mutex>>, sent: Mutex>>, } impl MockConn { fn new(packets: impl IntoIterator>) -> Self { Self { to_recv: Mutex::new(packets.into_iter().collect()), sent: Mutex::new(Vec::new()), } } } #[async_trait::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> { self.to_recv .lock() .await .pop_front() .ok_or_else(|| anyhow::anyhow!("mock conn drained")) } } /// A pushed-CRL envelope is decoded, verified, applied, and stripped from the recv stream; /// the next call returns the next real IP packet. #[tokio::test] async fn intercepts_crl_push_and_applies() { // Build a CA, sign a CRL of {"alice"}. let ca = AuraCa::generate("Aura Test").unwrap(); let ca_cert_pem = ca.ca_cert_pem(); // We need the CA key PEM. AuraCa does not expose it directly; round-trip via save/load. let cert_path = std::env::temp_dir().join(format!("aura-pki-test-{}-ca.crt", uuid::Uuid::new_v4())); let key_path = std::env::temp_dir().join(format!("aura-pki-test-{}-ca.key", uuid::Uuid::new_v4())); ca.save(&cert_path, &key_path).unwrap(); let ca_key_pem = std::fs::read_to_string(&key_path).unwrap(); let mut crl = CrlStore::new(); crl.revoke("alice"); let signed = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap(); let envelope = encode_control_envelope(ControlKind::CrlPush, &signed); // Build the inner mock: first packet is the CRL envelope, second is a real IPv4 packet. let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14, 0xab, 0xcd]; let inner: Arc = Arc::new(MockConn::new([envelope, ipv4.clone()])); // Cache to a temp file so we also exercise persistence. let cache_path = std::env::temp_dir().join(format!("aura-pki-test-{}-cached.crl", uuid::Uuid::new_v4())); let wrap = AcceptPushedCrlConn::new(inner, ca_cert_pem.clone(), Some(cache_path.clone()), true); // First recv: the envelope is consumed; the next packet (real IPv4) is returned. let pkt = wrap.recv_packet().await.unwrap(); assert_eq!(pkt, ipv4); // CRL was applied to the wrapper's last_applied slot. let applied = wrap.last_applied().read().await.clone(); assert!(applied.is_some(), "CRL should have been applied"); let applied = applied.unwrap(); assert!(applied.contains("alice")); // And persisted on disk in the v1 plain format. let from_disk = CrlStore::load(&cache_path).unwrap(); assert!(from_disk.contains("alice")); let _ = std::fs::remove_file(cache_path); let _ = std::fs::remove_file(cert_path); let _ = std::fs::remove_file(key_path); } /// A CRL push signed by a different CA must be dropped, the slot remains None, and the next /// real packet is still delivered. #[tokio::test] async fn rejects_crl_signed_by_wrong_ca() { let real = AuraCa::generate("Real").unwrap(); let rogue = AuraCa::generate("Rogue").unwrap(); let rogue_cert = std::env::temp_dir().join(format!("aura-pki-test-{}-r.crt", uuid::Uuid::new_v4())); let rogue_key = std::env::temp_dir().join(format!("aura-pki-test-{}-r.key", uuid::Uuid::new_v4())); rogue.save(&rogue_cert, &rogue_key).unwrap(); let rogue_key_pem = std::fs::read_to_string(&rogue_key).unwrap(); let rogue_cert_pem = std::fs::read_to_string(&rogue_cert).unwrap(); // Sign a CRL with the rogue CA but offer it to a client that trusts only `real`. let mut crl = CrlStore::new(); crl.revoke("alice"); let signed = crl.encode_signed(&rogue_cert_pem, &rogue_key_pem).unwrap(); let envelope = encode_control_envelope(ControlKind::CrlPush, &signed); let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14]; let inner: Arc = Arc::new(MockConn::new([envelope, ipv4.clone()])); let wrap = AcceptPushedCrlConn::new(inner, real.ca_cert_pem(), None, true); let pkt = wrap.recv_packet().await.unwrap(); assert_eq!(pkt, ipv4, "envelope dropped, real packet still delivered"); assert!( wrap.last_applied().read().await.is_none(), "no CRL should have been applied" ); let _ = std::fs::remove_file(rogue_cert); let _ = std::fs::remove_file(rogue_key); } /// When `accept = false`, the envelope is still stripped from the stream (so it does not /// pollute the TUN) but is NOT applied or persisted. #[tokio::test] async fn accept_false_strips_but_does_not_apply() { let ca = AuraCa::generate("Aura").unwrap(); let ca_cert_pem = ca.ca_cert_pem(); let cert_path = std::env::temp_dir().join(format!("aura-{}-c.crt", uuid::Uuid::new_v4())); let key_path = std::env::temp_dir().join(format!("aura-{}-c.key", uuid::Uuid::new_v4())); ca.save(&cert_path, &key_path).unwrap(); let ca_key_pem = std::fs::read_to_string(&key_path).unwrap(); let mut crl = CrlStore::new(); crl.revoke("alice"); let signed = crl.encode_signed(&ca_cert_pem, &ca_key_pem).unwrap(); let envelope = encode_control_envelope(ControlKind::CrlPush, &signed); let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14]; let inner: Arc = Arc::new(MockConn::new([envelope, ipv4.clone()])); let wrap = AcceptPushedCrlConn::new(inner, ca_cert_pem, None, false); let pkt = wrap.recv_packet().await.unwrap(); assert_eq!(pkt, ipv4); assert!(wrap.last_applied().read().await.is_none()); let _ = std::fs::remove_file(cert_path); let _ = std::fs::remove_file(key_path); } /// Two real packets in a row pass through unchanged. #[tokio::test] async fn passes_real_packets_through() { let real = AuraCa::generate("Real").unwrap(); let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14]; let ipv6 = vec![0x60u8, 0x00, 0x00, 0x00]; let inner: Arc = Arc::new(MockConn::new([ipv4.clone(), ipv6.clone()])); let wrap = AcceptPushedCrlConn::new(inner, real.ca_cert_pem(), None, true); assert_eq!(wrap.recv_packet().await.unwrap(), ipv4); assert_eq!(wrap.recv_packet().await.unwrap(), ipv6); } /// send_packet always passes through to the inner connection (the client never originates /// control envelopes — only the server does). #[tokio::test] async fn send_packet_passes_through() { let real = AuraCa::generate("Real").unwrap(); let inner = Arc::new(MockConn::new([])); let inner_arc: Arc = inner.clone(); let wrap = AcceptPushedCrlConn::new(Arc::clone(&inner_arc), real.ca_cert_pem(), None, true); wrap.send_packet(b"hello").await.unwrap(); let sent = inner.sent.lock().await.clone(); assert_eq!(sent, vec![b"hello".to_vec()]); } }