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,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)
|
||||
}
|
||||
Reference in New Issue
Block a user