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:
xah30
2026-05-25 17:55:06 +03:00
parent f78633e04f
commit b8ce58ddf0
18 changed files with 1712 additions and 5 deletions
+166
View File
@@ -0,0 +1,166 @@
//! The Aura certificate authority: generation, persistence and issuance.
use std::fs;
use std::path::Path;
use anyhow::Context;
use rcgen::string::Ia5String;
use rcgen::{
BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair,
KeyUsagePurpose, SanType,
};
use time::{Duration, OffsetDateTime};
/// Default lifetime of issued leaf certificates.
const LEAF_VALIDITY_DAYS: i64 = 365;
/// Default lifetime of the CA certificate.
const CA_VALIDITY_DAYS: i64 = 3650;
/// Small backdating of `not_before` to tolerate minor clock skew.
const CLOCK_SKEW_MINUTES: i64 = 5;
/// A freshly issued certificate together with its private key, both PEM-encoded.
#[derive(Debug, Clone)]
pub struct IssuedCert {
/// The leaf certificate in PEM format.
pub cert_pem: String,
/// The leaf's private key in PKCS#8 PEM format.
pub key_pem: String,
}
/// A self-signed Aura certificate authority.
///
/// Holds the CA's self-signed certificate (PEM) and `KeyPair`. The issuing
/// [`Issuer`] is rebuilt from the cert PEM + key for each signature, which makes
/// [`AuraCa::generate`] and [`AuraCa::load`] share exactly the same signing
/// path.
pub struct AuraCa {
cert_pem: String,
key: KeyPair,
}
impl AuraCa {
/// Generate a brand new self-signed CA with the given Common Name.
pub fn generate(common_name: &str) -> anyhow::Result<AuraCa> {
let key = KeyPair::generate().context("generating CA key pair")?;
let params = ca_params(common_name)?;
let cert = params
.self_signed(&key)
.context("self-signing the CA certificate")?;
Ok(AuraCa {
cert_pem: cert.pem(),
key,
})
}
/// Persist the CA certificate and private key to the given paths (PEM).
pub fn save(&self, cert_path: &Path, key_path: &Path) -> anyhow::Result<()> {
fs::write(cert_path, self.cert_pem.as_bytes())
.with_context(|| format!("writing CA cert to {}", cert_path.display()))?;
fs::write(key_path, self.key.serialize_pem().as_bytes())
.with_context(|| format!("writing CA key to {}", key_path.display()))?;
Ok(())
}
/// Load a CA previously written with [`AuraCa::save`].
pub fn load(cert_path: &Path, key_path: &Path) -> anyhow::Result<AuraCa> {
let cert_pem = fs::read_to_string(cert_path)
.with_context(|| format!("reading CA cert from {}", cert_path.display()))?;
let key_pem = fs::read_to_string(key_path)
.with_context(|| format!("reading CA key from {}", key_path.display()))?;
let key = KeyPair::from_pem(&key_pem).context("parsing CA key PEM")?;
let ca = AuraCa { cert_pem, key };
// Validate eagerly that the stored cert can be used as an issuer, so a
// corrupt CA fails at load time rather than at first issuance.
ca.issuer().context("validating loaded CA certificate")?;
Ok(ca)
}
/// Issue a server certificate carrying `DNS:domain` as a SAN and the
/// `serverAuth` extended key usage.
pub fn issue_server_cert(&self, domain: &str) -> anyhow::Result<IssuedCert> {
let leaf_key = KeyPair::generate().context("generating server leaf key")?;
let mut params = leaf_params(domain)?;
let dns = Ia5String::try_from(domain)
.with_context(|| format!("'{domain}' is not a valid IA5/DNS name"))?;
params.subject_alt_names.push(SanType::DnsName(dns));
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ServerAuth);
self.finish_leaf(params, leaf_key)
}
/// Issue a client certificate with `CN = client_id` and the `clientAuth`
/// extended key usage.
pub fn issue_client_cert(&self, client_id: &str) -> anyhow::Result<IssuedCert> {
let leaf_key = KeyPair::generate().context("generating client leaf key")?;
let mut params = leaf_params(client_id)?;
params
.extended_key_usages
.push(ExtendedKeyUsagePurpose::ClientAuth);
self.finish_leaf(params, leaf_key)
}
/// The CA's own self-signed certificate, PEM-encoded. Needed to build an
/// [`crate::AuraCertVerifier`].
pub fn ca_cert_pem(&self) -> String {
self.cert_pem.clone()
}
/// Rebuild an [`Issuer`] from the stored CA cert PEM + key.
fn issuer(&self) -> anyhow::Result<Issuer<'_, &KeyPair>> {
Issuer::from_ca_cert_pem(&self.cert_pem, &self.key)
.context("reconstructing issuer from CA certificate")
}
/// Sign a prepared leaf and bundle it with its key.
fn finish_leaf(
&self,
params: CertificateParams,
leaf_key: KeyPair,
) -> anyhow::Result<IssuedCert> {
let issuer = self.issuer()?;
let cert = params
.signed_by(&leaf_key, &issuer)
.context("signing leaf certificate with the CA")?;
Ok(IssuedCert {
cert_pem: cert.pem(),
key_pem: leaf_key.serialize_pem(),
})
}
}
/// Build the `CertificateParams` for a self-signed CA.
fn ca_params(common_name: &str) -> anyhow::Result<CertificateParams> {
let mut params = CertificateParams::new(Vec::<String>::new())
.context("constructing CA certificate params")?;
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params
.distinguished_name
.push(DnType::CommonName, common_name);
params.key_usages = vec![
KeyUsagePurpose::KeyCertSign,
KeyUsagePurpose::CrlSign,
KeyUsagePurpose::DigitalSignature,
];
let now = OffsetDateTime::now_utc();
params.not_before = now - Duration::minutes(CLOCK_SKEW_MINUTES);
params.not_after = now + Duration::days(CA_VALIDITY_DAYS);
Ok(params)
}
/// Build the common `CertificateParams` shared by client and server leaves.
/// `cn` is placed in the subject Common Name.
fn leaf_params(cn: &str) -> anyhow::Result<CertificateParams> {
let mut params = CertificateParams::new(Vec::<String>::new())
.context("constructing leaf certificate params")?;
params.is_ca = IsCa::NoCa;
params.distinguished_name.push(DnType::CommonName, cn);
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
let now = OffsetDateTime::now_utc();
params.not_before = now - Duration::minutes(CLOCK_SKEW_MINUTES);
params.not_after = now + Duration::days(LEAF_VALIDITY_DAYS);
Ok(params)
}
+186
View File
@@ -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)
}
+53 -1
View File
@@ -1 +1,53 @@
//! aura-pki — PKI: CA, certificate issuance and verification (skeleton; implemented in Wave 1).
//! aura-pki — mutual X.509 authentication for the Aura VPN.
//!
//! A self-signed Aura CA ([`AuraCa`]) issues a server certificate (carrying a
//! DNS SAN) and per-client certificates (`CN = client_id`). The server verifies
//! client certificates against the CA and the client verifies the server
//! certificate against the CA via [`AuraCertVerifier`]. v1 revocation is a
//! simple CRL: a set of revoked certificate serials or client ids, see
//! [`AuraCertVerifier::set_revoked`] and [`CrlStore`].
//!
//! Certificates use ECDSA P-256 / SHA-256 keys (rcgen's default), and chain
//! verification is performed with `rustls-webpki` against the CA trust anchor.
mod ca;
mod cert;
mod store;
pub use ca::{AuraCa, IssuedCert};
pub use cert::AuraCertVerifier;
pub use store::CrlStore;
/// Errors produced by the Aura PKI.
#[derive(Debug, thiserror::Error)]
pub enum PkiError {
/// A PEM/DER blob could not be parsed into a certificate.
#[error("failed to parse certificate: {0}")]
CertParse(String),
/// The supplied certificate chain was empty.
#[error("empty certificate chain")]
EmptyChain,
/// The trust anchor (CA certificate) could not be constructed.
#[error("invalid trust anchor: {0}")]
TrustAnchor(String),
/// Chain verification against the CA failed (bad signature, expired,
/// untrusted issuer, wrong key usage, ...).
#[error("certificate chain verification failed: {0}")]
Verification(String),
/// The server certificate is not valid for the requested name.
#[error("certificate is not valid for name '{0}'")]
NameMismatch(String),
/// The end-entity certificate did not contain the expected identity, e.g. a
/// client certificate without a Common Name.
#[error("missing identity in certificate: {0}")]
MissingIdentity(String),
/// The certificate was found in the revocation list.
#[error("certificate is revoked: {0}")]
Revoked(String),
}
+88
View File
@@ -0,0 +1,88 @@
//! 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.
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use anyhow::Context;
/// 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),
))
}
}
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);
}
}