//! 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 //! \n //! \n //! ... //! --SIGNATURE--\n //! \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, } 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) { 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 { 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 { 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> { // 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 { 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 { 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> { 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> { 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> { 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> { 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 { 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 for CrlStore { fn from_iter>(iter: T) -> Self { Self { revoked: iter.into_iter().collect(), } } } impl Extend for CrlStore { fn extend>(&mut self, iter: T) { self.revoked.extend(iter); } }