b8ce58ddf0
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>
187 lines
6.6 KiB
Rust
187 lines
6.6 KiB
Rust
//! 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)
|
|
}
|