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,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