feat(crypto,pki): implement Wave 1 — hybrid KEM + PKI
aura-crypto: X25519 + ML-KEM-768 (FIPS 203) hybrid KEM, HKDF-SHA256 session key derivation, ChaCha20-Poly1305 AeadSession with counter nonces; genuine NIST ACVP ML-KEM-768 KAT (decapsulation vector). 16 tests green, clippy clean. aura-pki: self-signed CA, server/client cert issuance (rcgen 0.14), mutual X.509 chain verification via rustls-webpki, CRL revocation. 8 tests green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
//! Certificate verification: chain validation against the Aura CA plus identity
|
||||
//! extraction and CRL (revocation) checks.
|
||||
|
||||
use rustls_pki_types::{CertificateDer, ServerName, UnixTime};
|
||||
use webpki::{anchor_from_trusted_cert, EndEntityCert, KeyUsage};
|
||||
use x509_parser::certificate::X509Certificate;
|
||||
use x509_parser::prelude::FromDer;
|
||||
|
||||
use crate::store::CrlStore;
|
||||
use crate::PkiError;
|
||||
|
||||
/// Signature algorithms accepted during chain verification.
|
||||
///
|
||||
/// rcgen's [`rcgen::KeyPair::generate`] defaults to ECDSA P-256 / SHA-256, so
|
||||
/// that algorithm must be present. The others are included so the verifier keeps
|
||||
/// working if a deployment switches the CA/leaf key type later.
|
||||
static SUPPORTED_ALGS: &[&dyn rustls_pki_types::SignatureVerificationAlgorithm] = &[
|
||||
webpki::ring::ECDSA_P256_SHA256,
|
||||
webpki::ring::ECDSA_P384_SHA384,
|
||||
webpki::ring::ED25519,
|
||||
];
|
||||
|
||||
/// Verifies certificate chains against a trusted Aura CA, with an optional CRL.
|
||||
pub struct AuraCertVerifier {
|
||||
/// DER of the trusted CA certificate (the trust anchor is borrowed from it
|
||||
/// per-verification, so we keep the owning bytes here).
|
||||
ca_der: CertificateDer<'static>,
|
||||
/// Revoked certificates, keyed by serial number (lowercase hex, no
|
||||
/// separators) and/or by Common Name / client id.
|
||||
crl: CrlStore,
|
||||
}
|
||||
|
||||
impl AuraCertVerifier {
|
||||
/// Build a verifier trusting the given CA certificate (PEM).
|
||||
pub fn new(ca_cert_pem: &str) -> Result<Self, PkiError> {
|
||||
let der = pem_to_der(ca_cert_pem)?;
|
||||
let ca_der = CertificateDer::from(der);
|
||||
// Validate up front that the CA is usable as a trust anchor.
|
||||
anchor_from_trusted_cert(&ca_der).map_err(|e| PkiError::TrustAnchor(e.to_string()))?;
|
||||
Ok(Self {
|
||||
ca_der,
|
||||
crl: CrlStore::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a CRL: a set of revoked serial numbers (hex) and/or client ids
|
||||
/// (Common Names). Replaces any previously configured set.
|
||||
pub fn set_revoked(&mut self, revoked: impl IntoIterator<Item = String>) {
|
||||
self.crl = CrlStore::from_iter(revoked);
|
||||
}
|
||||
|
||||
/// Access the underlying revocation store (e.g. to persist it).
|
||||
pub fn crl(&self) -> &CrlStore {
|
||||
&self.crl
|
||||
}
|
||||
|
||||
/// Verify a client certificate chain against the CA.
|
||||
///
|
||||
/// On success returns the client id (Common Name from the leaf subject).
|
||||
pub fn verify_client_cert(&self, cert_chain: &[CertificateDer]) -> Result<String, PkiError> {
|
||||
let leaf = self.verify_chain(cert_chain, KeyUsage::client_auth())?;
|
||||
let client_id = common_name(&leaf)?;
|
||||
self.check_not_revoked(&leaf, Some(&client_id))?;
|
||||
Ok(client_id)
|
||||
}
|
||||
|
||||
/// Verify a server certificate chain against the CA and that the leaf is
|
||||
/// valid for `server_name` (DNS SAN match).
|
||||
pub fn verify_server_cert(
|
||||
&self,
|
||||
cert_chain: &[CertificateDer],
|
||||
server_name: &str,
|
||||
) -> Result<(), PkiError> {
|
||||
let leaf = self.verify_chain(cert_chain, KeyUsage::server_auth())?;
|
||||
let name = ServerName::try_from(server_name)
|
||||
.map_err(|_| PkiError::NameMismatch(server_name.to_string()))?;
|
||||
leaf.verify_is_valid_for_subject_name(&name)
|
||||
.map_err(|_| PkiError::NameMismatch(server_name.to_string()))?;
|
||||
self.check_not_revoked(&leaf, None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run webpki chain verification and return the parsed end-entity cert.
|
||||
fn verify_chain<'a>(
|
||||
&self,
|
||||
cert_chain: &'a [CertificateDer],
|
||||
usage: KeyUsage,
|
||||
) -> Result<EndEntityCert<'a>, PkiError> {
|
||||
let (leaf_der, intermediates) = cert_chain.split_first().ok_or(PkiError::EmptyChain)?;
|
||||
|
||||
let leaf =
|
||||
EndEntityCert::try_from(leaf_der).map_err(|e| PkiError::CertParse(e.to_string()))?;
|
||||
|
||||
let anchor = anchor_from_trusted_cert(&self.ca_der)
|
||||
.map_err(|e| PkiError::TrustAnchor(e.to_string()))?;
|
||||
let anchors = [anchor];
|
||||
|
||||
leaf.verify_for_usage(
|
||||
SUPPORTED_ALGS,
|
||||
&anchors,
|
||||
intermediates,
|
||||
UnixTime::now(),
|
||||
usage,
|
||||
None, // revocation handled separately via the simple CRL store
|
||||
None,
|
||||
)
|
||||
.map_err(|e| PkiError::Verification(e.to_string()))?;
|
||||
|
||||
Ok(leaf)
|
||||
}
|
||||
|
||||
/// Reject the leaf if its serial or Common Name is in the CRL.
|
||||
fn check_not_revoked(
|
||||
&self,
|
||||
leaf: &EndEntityCert<'_>,
|
||||
known_cn: Option<&str>,
|
||||
) -> Result<(), PkiError> {
|
||||
if self.crl.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Extract owned identifiers, then drop the parsed cert before checking.
|
||||
let (serial, cn) = {
|
||||
let der = leaf.der();
|
||||
let (_, parsed) = X509Certificate::from_der(der.as_ref())
|
||||
.map_err(|e| PkiError::CertParse(e.to_string()))?;
|
||||
let serial = serial_hex(&parsed);
|
||||
let cn = match known_cn {
|
||||
Some(cn) => Some(cn.to_string()),
|
||||
None => parsed
|
||||
.subject()
|
||||
.iter_common_name()
|
||||
.next()
|
||||
.and_then(|a| a.as_str().ok())
|
||||
.map(str::to_string),
|
||||
};
|
||||
(serial, cn)
|
||||
};
|
||||
|
||||
if self.crl.contains(&serial) {
|
||||
return Err(PkiError::Revoked(format!("serial {serial}")));
|
||||
}
|
||||
if let Some(cn) = cn {
|
||||
if self.crl.contains(&cn) {
|
||||
return Err(PkiError::Revoked(format!("client id {cn}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the first Common Name from the leaf's subject.
|
||||
fn common_name(leaf: &EndEntityCert<'_>) -> Result<String, PkiError> {
|
||||
let der = leaf.der();
|
||||
let (_, parsed) =
|
||||
X509Certificate::from_der(der.as_ref()).map_err(|e| PkiError::CertParse(e.to_string()))?;
|
||||
let cn = parsed
|
||||
.subject()
|
||||
.iter_common_name()
|
||||
.next()
|
||||
.and_then(|attr| attr.as_str().ok())
|
||||
.map(str::to_string);
|
||||
cn.ok_or_else(|| PkiError::MissingIdentity("no Common Name in subject".into()))
|
||||
}
|
||||
|
||||
/// Lowercase, separator-free hex of the certificate serial number.
|
||||
fn serial_hex(cert: &X509Certificate<'_>) -> String {
|
||||
cert.tbs_certificate
|
||||
.raw_serial()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Decode the first certificate from a PEM string into DER bytes.
|
||||
fn pem_to_der(pem: &str) -> Result<Vec<u8>, PkiError> {
|
||||
let (_, item) = x509_parser::pem::parse_x509_pem(pem.as_bytes())
|
||||
.map_err(|e| PkiError::CertParse(format!("invalid PEM: {e}")))?;
|
||||
if item.label != "CERTIFICATE" {
|
||||
return Err(PkiError::CertParse(format!(
|
||||
"expected CERTIFICATE PEM block, found '{}'",
|
||||
item.label
|
||||
)));
|
||||
}
|
||||
Ok(item.contents)
|
||||
}
|
||||
Reference in New Issue
Block a user