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:
@@ -4,12 +4,36 @@
|
||||
//! identifier strings. An identifier is either a certificate serial number
|
||||
//! (lowercase hex, no separators) or a client id / Common Name. A certificate
|
||||
//! is rejected if any of those identifiers is present in the set.
|
||||
//!
|
||||
//! ## v2 signed wire format
|
||||
//!
|
||||
//! [`CrlStore::save_signed`] / [`CrlStore::load_signed_verified`] add an ECDSA-P256/SHA-256
|
||||
//! signature over the unsigned text body so the in-band CRL push (server -> client) is tamper-
|
||||
//! evident even though the existing AEAD session already binds the link to the verified server
|
||||
//! identity. The on-disk / on-wire layout is:
|
||||
//!
|
||||
//! ```text
|
||||
//! CRL-Aura-v1\n
|
||||
//! <id-1>\n
|
||||
//! <id-2>\n
|
||||
//! ...
|
||||
//! --SIGNATURE--\n
|
||||
//! <hex-encoded ECDSA-P256 signature over the bytes *before* this marker line>\n
|
||||
//! ```
|
||||
//!
|
||||
//! The signed bytes are everything up to and including the newline at the end of the last id (the
|
||||
//! `"--SIGNATURE--\n"` marker is **not** part of the signed input). Verification recovers the CA
|
||||
//! public key from the CA certificate PEM and checks the signature with `ring`.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::{anyhow, Context};
|
||||
use ring::signature::{
|
||||
EcdsaKeyPair, UnparsedPublicKey, ECDSA_P256_SHA256_ASN1, ECDSA_P256_SHA256_ASN1_SIGNING,
|
||||
};
|
||||
use x509_parser::prelude::FromDer;
|
||||
|
||||
/// A set of revoked certificate identifiers (serials and/or client ids).
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
@@ -71,6 +95,192 @@ impl CrlStore {
|
||||
.map(str::to_string),
|
||||
))
|
||||
}
|
||||
|
||||
/// Produce the signed wire/disk bytes (header + ids + `--SIGNATURE--` block) for this CRL.
|
||||
///
|
||||
/// The body up to and including the last id's trailing newline is signed with the CA's
|
||||
/// ECDSA-P256/SHA-256 key; the signature is appended hex-encoded after the marker. The exact
|
||||
/// layout is described in the module-level docs.
|
||||
///
|
||||
/// `ca_cert_pem` is included for parity with [`Self::load_signed_verified`] but is only used
|
||||
/// to validate the operator did not pass mismatched material — the signing path itself only
|
||||
/// needs the key PEM.
|
||||
pub fn encode_signed(&self, ca_cert_pem: &str, ca_key_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||
// Sanity-check the CA cert PEM is parseable so we never write a CRL the loader cannot
|
||||
// verify against the same anchor.
|
||||
ca_public_key_from_pem(ca_cert_pem).context("invalid CA certificate PEM for signing")?;
|
||||
|
||||
let body = self.signed_body();
|
||||
let signature =
|
||||
sign_ecdsa_p256(ca_key_pem, body.as_bytes()).context("signing CRL with the CA key")?;
|
||||
let mut out = Vec::with_capacity(body.len() + 32 + signature.len() * 2);
|
||||
out.extend_from_slice(body.as_bytes());
|
||||
out.extend_from_slice(SIGNATURE_MARKER);
|
||||
out.extend_from_slice(hex_encode(&signature).as_bytes());
|
||||
out.push(b'\n');
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Persist the CRL in the signed v2 format under `path` (creating parent dirs as needed).
|
||||
pub fn save_signed(
|
||||
&self,
|
||||
path: &Path,
|
||||
ca_cert_pem: &str,
|
||||
ca_key_pem: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let bytes = self.encode_signed(ca_cert_pem, ca_key_pem)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("creating CRL dir {}", parent.display()))?;
|
||||
}
|
||||
}
|
||||
fs::write(path, &bytes)
|
||||
.with_context(|| format!("writing signed CRL to {}", path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a signed CRL blob and verify its signature against the CA cert PEM.
|
||||
///
|
||||
/// On success the parsed [`CrlStore`] is returned. Any tampering (modified body or signature)
|
||||
/// yields an `Err` so the caller can refuse to apply a non-authentic CRL.
|
||||
pub fn decode_signed_verified(bytes: &[u8], ca_cert_pem: &str) -> anyhow::Result<Self> {
|
||||
let text = std::str::from_utf8(bytes)
|
||||
.map_err(|e| anyhow!("signed CRL is not valid UTF-8: {e}"))?;
|
||||
let marker = std::str::from_utf8(SIGNATURE_MARKER)
|
||||
.expect("SIGNATURE_MARKER is a static ASCII literal");
|
||||
let idx = text
|
||||
.find(marker)
|
||||
.ok_or_else(|| anyhow!("signed CRL missing '--SIGNATURE--' marker"))?;
|
||||
let body = &text[..idx];
|
||||
let sig_text = text[idx + marker.len()..].trim();
|
||||
let signature = hex_decode(sig_text).context("decoding signed CRL hex signature")?;
|
||||
|
||||
let pubkey = ca_public_key_from_pem(ca_cert_pem)
|
||||
.context("loading CA public key for CRL verification")?;
|
||||
UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, pubkey.as_slice())
|
||||
.verify(body.as_bytes(), &signature)
|
||||
.map_err(|_| anyhow!("signed CRL signature did not verify"))?;
|
||||
|
||||
// Parse the inner body. Skip the magic line, then keep non-empty / non-comment lines.
|
||||
let mut lines = body.lines();
|
||||
let header = lines
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("empty signed CRL body"))?;
|
||||
if header.trim() != SIGNED_CRL_HEADER {
|
||||
return Err(anyhow!(
|
||||
"unexpected signed CRL header '{header}', expected '{SIGNED_CRL_HEADER}'"
|
||||
));
|
||||
}
|
||||
Ok(Self::from_iter(
|
||||
lines
|
||||
.map(str::trim)
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.map(str::to_string),
|
||||
))
|
||||
}
|
||||
|
||||
/// Load a signed CRL file (the inverse of [`Self::save_signed`]) and verify its signature.
|
||||
pub fn load_signed_verified(path: &Path, ca_cert_pem: &str) -> anyhow::Result<Self> {
|
||||
let bytes = fs::read(path)
|
||||
.with_context(|| format!("reading signed CRL from {}", path.display()))?;
|
||||
Self::decode_signed_verified(&bytes, ca_cert_pem)
|
||||
}
|
||||
|
||||
/// Internal: produce the bytes that get signed (header + ids).
|
||||
fn signed_body(&self) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(SIGNED_CRL_HEADER);
|
||||
s.push('\n');
|
||||
for id in &self.revoked {
|
||||
s.push_str(id);
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// First line of the signed CRL body.
|
||||
const SIGNED_CRL_HEADER: &str = "CRL-Aura-v1";
|
||||
/// Bytes separating the signed body from the hex signature.
|
||||
const SIGNATURE_MARKER: &[u8] = b"--SIGNATURE--\n";
|
||||
|
||||
/// Sign `body` with an ECDSA-P256/SHA-256 PKCS#8 key (PEM-encoded). Returns the ASN.1 signature
|
||||
/// bytes (variable-length DER) that `ring::signature::ECDSA_P256_SHA256_ASN1` accepts on verify.
|
||||
fn sign_ecdsa_p256(ca_key_pem: &str, body: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
let pkcs8_der = pem_block_to_der(ca_key_pem, &["PRIVATE KEY", "EC PRIVATE KEY"])
|
||||
.ok_or_else(|| anyhow!("no PKCS#8 private-key block in CA key PEM"))?;
|
||||
let rng = ring::rand::SystemRandom::new();
|
||||
let key_pair = EcdsaKeyPair::from_pkcs8(&ECDSA_P256_SHA256_ASN1_SIGNING, &pkcs8_der, &rng)
|
||||
.map_err(|e| anyhow!("invalid CA PKCS#8 ECDSA P-256 key: {e}"))?;
|
||||
let sig = key_pair
|
||||
.sign(&rng, body)
|
||||
.map_err(|e| anyhow!("ECDSA signing failed: {e}"))?;
|
||||
Ok(sig.as_ref().to_vec())
|
||||
}
|
||||
|
||||
/// Extract the CA's uncompressed EC public-key point from a CA certificate PEM.
|
||||
fn ca_public_key_from_pem(ca_cert_pem: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let der = pem_block_to_der(ca_cert_pem, &["CERTIFICATE"])
|
||||
.ok_or_else(|| anyhow!("no CERTIFICATE block in CA PEM"))?;
|
||||
let (_, cert) = x509_parser::certificate::X509Certificate::from_der(&der)
|
||||
.map_err(|e| anyhow!("failed to parse CA certificate DER: {e}"))?;
|
||||
Ok(cert.public_key().subject_public_key.data.to_vec())
|
||||
}
|
||||
|
||||
/// Iterate PEM blocks and return the first whose label matches one of `labels`.
|
||||
fn pem_block_to_der(pem: &str, labels: &[&str]) -> Option<Vec<u8>> {
|
||||
for item in x509_parser::pem::Pem::iter_from_buffer(pem.as_bytes()) {
|
||||
let item = item.ok()?;
|
||||
if labels.contains(&item.label.as_str()) {
|
||||
return Some(item.contents);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Lowercase hex of a byte slice.
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(bytes.len() * 2);
|
||||
for b in bytes {
|
||||
s.push(nibble_to_hex(b >> 4));
|
||||
s.push(nibble_to_hex(b & 0x0F));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Decode a lowercase/uppercase hex string into bytes. Returns an error on any non-hex character or
|
||||
/// odd length.
|
||||
fn hex_decode(s: &str) -> anyhow::Result<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if !s.len().is_multiple_of(2) {
|
||||
return Err(anyhow!("hex string has odd length ({} chars)", s.len()));
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let bytes = s.as_bytes();
|
||||
for chunk in bytes.chunks_exact(2) {
|
||||
let hi = hex_to_nibble(chunk[0])?;
|
||||
let lo = hex_to_nibble(chunk[1])?;
|
||||
out.push((hi << 4) | lo);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn nibble_to_hex(n: u8) -> char {
|
||||
match n {
|
||||
0..=9 => (b'0' + n) as char,
|
||||
10..=15 => (b'a' + n - 10) as char,
|
||||
_ => '?',
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_to_nibble(c: u8) -> anyhow::Result<u8> {
|
||||
match c {
|
||||
b'0'..=b'9' => Ok(c - b'0'),
|
||||
b'a'..=b'f' => Ok(c - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(c - b'A' + 10),
|
||||
other => Err(anyhow!("invalid hex character 0x{other:02x}")),
|
||||
}
|
||||
}
|
||||
|
||||
impl FromIterator<String> for CrlStore {
|
||||
|
||||
Reference in New Issue
Block a user