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
+145
View File
@@ -176,6 +176,48 @@ mod frame_tag {
pub const CLOSE: u8 = 0x04;
}
/// Kinds of in-band control message carried inside a [`CONTROL_ENVELOPE_MAGIC`]-prefixed payload.
///
/// The wire byte is the discriminant. Unknown values decode as [`ControlKind::Unknown`] so peers
/// running older builds gracefully ignore future kinds without dropping the connection.
///
/// v2's CRL push reuses the existing post-handshake [`crate::PacketConnection::send_packet`] path
/// rather than introducing a new [`Frame`] variant: a real IPv4/IPv6 packet always starts with
/// `0x4X` / `0x6X`, so the 4-byte magic [`CONTROL_ENVELOPE_MAGIC`] (which starts with `0xAA`) can
/// be safely multiplexed alongside ordinary packets without changing the on-wire frame schema or
/// any transport-level `match Frame` that already exists.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ControlKind {
/// Server -> client: push the server's current CRL (signed payload).
CrlPush,
/// Client -> server: acknowledge a [`ControlKind::CrlPush`].
CrlAck,
/// Any byte the receiver does not recognise. The connection keeps running.
Unknown(u8),
}
impl ControlKind {
/// Encode this control kind to its on-wire byte.
#[must_use]
pub fn to_u8(self) -> u8 {
match self {
ControlKind::CrlPush => 0x01,
ControlKind::CrlAck => 0x02,
ControlKind::Unknown(b) => b,
}
}
/// Decode an on-wire byte into a [`ControlKind`]. Unknown bytes yield [`ControlKind::Unknown`].
#[must_use]
pub fn from_u8(b: u8) -> Self {
match b {
0x01 => ControlKind::CrlPush,
0x02 => ControlKind::CrlAck,
other => ControlKind::Unknown(other),
}
}
}
/// Application-level frames carried inside encrypted [`MsgType::Data`] records (§6.3).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Frame {
@@ -289,6 +331,64 @@ fn read_u32(buf: &[u8], what: &'static str) -> Result<u32, ProtoError> {
Ok(u32::from_be_bytes(bytes))
}
/// Magic prefix marking a v2 control-envelope multiplexed through [`PacketConnection::send_packet`].
///
/// An IPv4 packet's first byte is `0x4X` and an IPv6 packet's first byte is `0x6X`, so the four
/// magic bytes `[0xAA, 0xAA, 0xC0, 0x01]` can never collide with a real IP packet — the TUN layer
/// already rejects anything starting with a byte whose top nibble is not `4` or `6`.
///
/// Envelope layout:
///
/// ```text
/// CONTROL_ENVELOPE_MAGIC (4 bytes) || kind (u8) || u32_be(payload_len) || payload
/// ```
pub const CONTROL_ENVELOPE_MAGIC: [u8; 4] = [0xAA, 0xAA, 0xC0, 0x01];
/// Build a control envelope around `kind` + `payload`, suitable for
/// [`crate::PacketConnection::send_packet`].
///
/// Layout: `MAGIC(4) || kind(u8) || u32_be(payload_len) || payload`.
#[must_use]
pub fn encode_control_envelope(kind: ControlKind, payload: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(CONTROL_ENVELOPE_MAGIC.len() + 1 + 4 + payload.len());
out.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
out.push(kind.to_u8());
out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
out.extend_from_slice(payload);
out
}
/// Try to decode a buffer as a control envelope.
///
/// Returns `None` if `buf` does not start with [`CONTROL_ENVELOPE_MAGIC`] (i.e. it is a normal IP
/// packet). Returns [`ProtoError::MalformedFrame`] if the buffer starts with the magic but is
/// truncated or its length field overflows the buffer.
pub fn decode_control_envelope(buf: &[u8]) -> Result<Option<(ControlKind, Vec<u8>)>, ProtoError> {
if buf.len() < CONTROL_ENVELOPE_MAGIC.len() || &buf[..4] != CONTROL_ENVELOPE_MAGIC.as_slice() {
return Ok(None);
}
let rest = &buf[CONTROL_ENVELOPE_MAGIC.len()..];
let kind_byte = *rest
.first()
.ok_or(ProtoError::MalformedFrame("control envelope: missing kind"))?;
let kind = ControlKind::from_u8(kind_byte);
let len_bytes: [u8; 4] = rest
.get(1..5)
.ok_or(ProtoError::MalformedFrame(
"control envelope: missing payload length",
))?
.try_into()
.expect("slice of length 4 converts to [u8; 4]");
let payload_len = u32::from_be_bytes(len_bytes) as usize;
let payload = rest
.get(5..5 + payload_len)
.ok_or(ProtoError::MalformedFrame(
"control envelope: truncated payload",
))?
.to_vec();
Ok(Some((kind, payload)))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -368,4 +468,49 @@ mod tests {
assert!(Frame::decode(&[frame_tag::PING, 0x00]).is_err()); // truncated u32
assert!(Frame::decode(&[frame_tag::CLOSE]).is_err()); // missing code
}
#[test]
fn control_envelope_roundtrip() {
let env = encode_control_envelope(ControlKind::CrlPush, b"hello");
assert_eq!(&env[..4], &CONTROL_ENVELOPE_MAGIC);
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
assert_eq!(kind, ControlKind::CrlPush);
assert_eq!(payload, b"hello");
}
#[test]
fn control_envelope_skips_normal_ip_packets() {
// IPv4 packet: first byte's top nibble is 4. Never collides with magic.
let ipv4 = vec![0x45u8, 0x00, 0x00, 0x14];
assert!(decode_control_envelope(&ipv4).unwrap().is_none());
// IPv6 packet: first byte's top nibble is 6.
let ipv6 = vec![0x60u8, 0x00, 0x00, 0x00];
assert!(decode_control_envelope(&ipv6).unwrap().is_none());
// Random short bytes that do not match the magic.
let other = vec![0xAAu8, 0xAA, 0xC0, 0x02];
assert!(decode_control_envelope(&other).unwrap().is_none());
// Shorter than the magic.
assert!(decode_control_envelope(&[0xAA, 0xAA]).unwrap().is_none());
}
#[test]
fn control_envelope_rejects_truncated_payload() {
let mut env = encode_control_envelope(ControlKind::CrlPush, b"payload-bytes");
// Trim a few bytes from the end to truncate the payload claimed by the length field.
env.truncate(env.len() - 3);
assert!(decode_control_envelope(&env).is_err());
}
#[test]
fn control_envelope_unknown_kind_decodes_as_unknown() {
// Hand-craft an envelope with a future kind byte.
let mut env = Vec::new();
env.extend_from_slice(&CONTROL_ENVELOPE_MAGIC);
env.push(0x77); // unknown kind
env.extend_from_slice(&3u32.to_be_bytes());
env.extend_from_slice(b"abc");
let (kind, payload) = decode_control_envelope(&env).unwrap().unwrap();
assert_eq!(kind, ControlKind::Unknown(0x77));
assert_eq!(payload, b"abc");
}
}