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:
@@ -6,10 +6,17 @@ license.workspace = true
|
||||
description = "Aura PKI: CA, X.509 issuance and mutual-auth verification"
|
||||
|
||||
[dependencies]
|
||||
rcgen.workspace = true
|
||||
# `x509-parser` feature enables Issuer::from_ca_cert_pem (parsing an existing CA
|
||||
# cert to reconstruct the issuer). Merged on top of the workspace default features.
|
||||
rcgen = { workspace = true, features = ["x509-parser"] }
|
||||
rustls.workspace = true
|
||||
rustls-pki-types.workspace = true
|
||||
x509-parser.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
anyhow.workspace = true
|
||||
# Chain verification against the Aura CA trust anchor. 0.103 is already in the
|
||||
# workspace lockfile (pulled transitively), so this adds no new resolution.
|
||||
webpki = { package = "rustls-webpki", version = "0.103", default-features = false, features = ["ring"] }
|
||||
# Certificate validity windows (not_before / not_after). Already in the lockfile.
|
||||
time = { version = "0.3", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Integration tests for the Aura PKI: issuance, mutual-auth verification and
|
||||
//! revocation.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use aura_pki::{AuraCa, AuraCertVerifier};
|
||||
use rustls_pki_types::CertificateDer;
|
||||
use uuid::Uuid;
|
||||
use x509_parser::prelude::FromDer;
|
||||
|
||||
/// Decode the first CERTIFICATE block of a PEM string into owned DER.
|
||||
fn pem_to_der(pem: &str) -> CertificateDer<'static> {
|
||||
let (_, item) =
|
||||
x509_parser::pem::parse_x509_pem(pem.as_bytes()).expect("issued cert is valid PEM");
|
||||
assert_eq!(item.label, "CERTIFICATE");
|
||||
CertificateDer::from(item.contents)
|
||||
}
|
||||
|
||||
/// A unique temp path under the OS temp dir so parallel tests don't collide.
|
||||
fn temp_path(suffix: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("aura-pki-test-{}-{suffix}", Uuid::new_v4()));
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ca_issue_server_cert() {
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let server = ca.issue_server_cert("vpn.example.com").unwrap();
|
||||
let chain = [pem_to_der(&server.cert_pem)];
|
||||
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
|
||||
// Correct name verifies.
|
||||
verifier
|
||||
.verify_server_cert(&chain, "vpn.example.com")
|
||||
.expect("server cert should verify for its SAN");
|
||||
|
||||
// Wrong name is rejected.
|
||||
let err = verifier.verify_server_cert(&chain, "evil.example.com");
|
||||
assert!(err.is_err(), "wrong server name must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ca_issue_client_cert() {
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let client = ca.issue_client_cert("alice").unwrap();
|
||||
let chain = [pem_to_der(&client.cert_pem)];
|
||||
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
let client_id = verifier
|
||||
.verify_client_cert(&chain)
|
||||
.expect("client cert should verify against its CA");
|
||||
assert_eq!(client_id, "alice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ca_issue_client_cert_uuid_cn() {
|
||||
// The spec notes client CNs are typically UUIDs; make sure that round-trips.
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let client = ca.issue_client_cert(&id).unwrap();
|
||||
let chain = [pem_to_der(&client.cert_pem)];
|
||||
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
assert_eq!(verifier.verify_client_cert(&chain).unwrap(), id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_cert_rejected() {
|
||||
// A leaf from an independent CA must not verify against the first CA.
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let rogue_ca = AuraCa::generate("Rogue CA").unwrap();
|
||||
|
||||
let rogue_client = rogue_ca.issue_client_cert("mallory").unwrap();
|
||||
let rogue_server = rogue_ca.issue_server_cert("vpn.example.com").unwrap();
|
||||
let client_chain = [pem_to_der(&rogue_client.cert_pem)];
|
||||
let server_chain = [pem_to_der(&rogue_server.cert_pem)];
|
||||
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
|
||||
assert!(
|
||||
verifier.verify_client_cert(&client_chain).is_err(),
|
||||
"client cert from a different CA must be rejected"
|
||||
);
|
||||
assert!(
|
||||
verifier
|
||||
.verify_server_cert(&server_chain, "vpn.example.com")
|
||||
.is_err(),
|
||||
"server cert from a different CA must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_revoked_cert_rejected() {
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let client = ca.issue_client_cert("bob").unwrap();
|
||||
let chain = [pem_to_der(&client.cert_pem)];
|
||||
|
||||
let mut verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
|
||||
// Before revocation: valid.
|
||||
assert_eq!(verifier.verify_client_cert(&chain).unwrap(), "bob");
|
||||
|
||||
// Revoke by client id (Common Name) and confirm rejection.
|
||||
verifier.set_revoked(["bob".to_string()]);
|
||||
let err = verifier.verify_client_cert(&chain);
|
||||
assert!(err.is_err(), "revoked client id must be rejected");
|
||||
|
||||
// Also exercise revoking by serial number (hex of the raw serial).
|
||||
let serial = {
|
||||
let der = pem_to_der(&client.cert_pem);
|
||||
let (_, parsed) =
|
||||
x509_parser::certificate::X509Certificate::from_der(der.as_ref()).unwrap();
|
||||
parsed
|
||||
.tbs_certificate
|
||||
.raw_serial()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let mut verifier2 = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
verifier2.set_revoked([serial]);
|
||||
assert!(
|
||||
verifier2.verify_client_cert(&chain).is_err(),
|
||||
"revoked serial must be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_load_roundtrip() {
|
||||
let cert_path = temp_path("ca.pem");
|
||||
let key_path = temp_path("ca.key");
|
||||
|
||||
let original = AuraCa::generate("Aura Persisted CA").unwrap();
|
||||
original.save(&cert_path, &key_path).unwrap();
|
||||
|
||||
let loaded = AuraCa::load(&cert_path, &key_path).unwrap();
|
||||
|
||||
// The loaded CA presents the same anchor certificate...
|
||||
assert_eq!(original.ca_cert_pem(), loaded.ca_cert_pem());
|
||||
|
||||
// ...and can still issue certs that verify against that anchor.
|
||||
let client = loaded.issue_client_cert("carol").unwrap();
|
||||
let chain = [pem_to_der(&client.cert_pem)];
|
||||
let verifier = AuraCertVerifier::new(&loaded.ca_cert_pem()).unwrap();
|
||||
assert_eq!(verifier.verify_client_cert(&chain).unwrap(), "carol");
|
||||
|
||||
// A cert issued by the loaded CA also verifies against the ORIGINAL CA's
|
||||
// anchor (same key + subject), proving the identity survived the round-trip.
|
||||
let verifier_orig = AuraCertVerifier::new(&original.ca_cert_pem()).unwrap();
|
||||
assert_eq!(verifier_orig.verify_client_cert(&chain).unwrap(), "carol");
|
||||
|
||||
let _ = std::fs::remove_file(&cert_path);
|
||||
let _ = std::fs::remove_file(&key_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_chain_rejected() {
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
let empty: [CertificateDer; 0] = [];
|
||||
assert!(verifier.verify_client_cert(&empty).is_err());
|
||||
assert!(verifier
|
||||
.verify_server_cert(&empty, "vpn.example.com")
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_client_cert_not_valid_as_server_name() {
|
||||
// A client cert has no DNS SAN, so server-name verification must fail even
|
||||
// though the chain itself is trusted.
|
||||
let ca = AuraCa::generate("Aura Test CA").unwrap();
|
||||
let client = ca.issue_client_cert("dave").unwrap();
|
||||
let chain = [pem_to_der(&client.cert_pem)];
|
||||
let verifier = AuraCertVerifier::new(&ca.ca_cert_pem()).unwrap();
|
||||
assert!(verifier.verify_server_cert(&chain, "dave").is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user