35d94dee33
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>
299 lines
11 KiB
Rust
299 lines
11 KiB
Rust
//! Trust material storage: a simple v1 CRL (set of revoked identifiers).
|
|
//!
|
|
//! The Aura v1 revocation list is deliberately minimal: a set of opaque
|
|
//! 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::{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)]
|
|
pub struct CrlStore {
|
|
revoked: BTreeSet<String>,
|
|
}
|
|
|
|
impl CrlStore {
|
|
/// Create an empty CRL.
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
/// Add a single revoked identifier (serial hex or client id).
|
|
pub fn revoke(&mut self, id: impl Into<String>) {
|
|
self.revoked.insert(id.into());
|
|
}
|
|
|
|
/// True if `id` is in the revocation set.
|
|
pub fn contains(&self, id: &str) -> bool {
|
|
self.revoked.contains(id)
|
|
}
|
|
|
|
/// True if no certificates are revoked.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.revoked.is_empty()
|
|
}
|
|
|
|
/// Number of revoked identifiers.
|
|
pub fn len(&self) -> usize {
|
|
self.revoked.len()
|
|
}
|
|
|
|
/// Iterate over the revoked identifiers (sorted).
|
|
pub fn iter(&self) -> impl Iterator<Item = &str> {
|
|
self.revoked.iter().map(String::as_str)
|
|
}
|
|
|
|
/// Persist the CRL, one identifier per line.
|
|
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
|
|
let mut body = String::new();
|
|
for id in &self.revoked {
|
|
body.push_str(id);
|
|
body.push('\n');
|
|
}
|
|
fs::write(path, body).with_context(|| format!("writing CRL to {}", path.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Load a CRL written by [`CrlStore::save`] (one identifier per line; blank
|
|
/// lines and `#` comments are ignored).
|
|
pub fn load(path: &Path) -> anyhow::Result<Self> {
|
|
let text = fs::read_to_string(path)
|
|
.with_context(|| format!("reading CRL from {}", path.display()))?;
|
|
Ok(Self::from_iter(
|
|
text.lines()
|
|
.map(str::trim)
|
|
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
|
.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 {
|
|
fn from_iter<T: IntoIterator<Item = String>>(iter: T) -> Self {
|
|
Self {
|
|
revoked: iter.into_iter().collect(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Extend<String> for CrlStore {
|
|
fn extend<T: IntoIterator<Item = String>>(&mut self, iter: T) {
|
|
self.revoked.extend(iter);
|
|
}
|
|
}
|