From b8ce58ddf03fd3c42006b647921e077ce7c8dbc2 Mon Sep 17 00:00:00 2001 From: xah30 Date: Mon, 25 May 2026 17:55:06 +0300 Subject: [PATCH] =?UTF-8?q?feat(crypto,pki):=20implement=20Wave=201=20?= =?UTF-8?q?=E2=80=94=20hybrid=20KEM=20+=20PKI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 4 + crates/aura-crypto/Cargo.toml | 14 +- crates/aura-crypto/benches/crypto.rs | 96 ++++++++++ crates/aura-crypto/src/aead.rs | 158 ++++++++++++++++ crates/aura-crypto/src/kdf.rs | 76 ++++++++ crates/aura-crypto/src/kem/hybrid.rs | 149 +++++++++++++++ crates/aura-crypto/src/kem/kyber.rs | 124 ++++++++++++ crates/aura-crypto/src/kem/mod.rs | 17 ++ crates/aura-crypto/src/kem/x25519.rs | 30 +++ crates/aura-crypto/src/lib.rs | 47 ++++- crates/aura-crypto/tests/hybrid_kat.rs | 249 +++++++++++++++++++++++++ crates/aura-crypto/tests/kat_kyber.rs | 72 +++++++ crates/aura-pki/Cargo.toml | 9 +- crates/aura-pki/src/ca.rs | 166 +++++++++++++++++ crates/aura-pki/src/cert.rs | 186 ++++++++++++++++++ crates/aura-pki/src/lib.rs | 54 +++++- crates/aura-pki/src/store.rs | 88 +++++++++ crates/aura-pki/tests/pki.rs | 178 ++++++++++++++++++ 18 files changed, 1712 insertions(+), 5 deletions(-) create mode 100644 crates/aura-crypto/benches/crypto.rs create mode 100644 crates/aura-crypto/src/aead.rs create mode 100644 crates/aura-crypto/src/kdf.rs create mode 100644 crates/aura-crypto/src/kem/hybrid.rs create mode 100644 crates/aura-crypto/src/kem/kyber.rs create mode 100644 crates/aura-crypto/src/kem/mod.rs create mode 100644 crates/aura-crypto/src/kem/x25519.rs create mode 100644 crates/aura-crypto/tests/hybrid_kat.rs create mode 100644 crates/aura-crypto/tests/kat_kyber.rs create mode 100644 crates/aura-pki/src/ca.rs create mode 100644 crates/aura-pki/src/cert.rs create mode 100644 crates/aura-pki/src/store.rs create mode 100644 crates/aura-pki/tests/pki.rs diff --git a/Cargo.lock b/Cargo.lock index 9d44a01..32ae0c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -231,7 +231,9 @@ dependencies = [ "rcgen", "rustls", "rustls-pki-types", + "rustls-webpki", "thiserror 1.0.69", + "time", "uuid", "x509-parser 0.16.0", ] @@ -719,6 +721,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.2", "hybrid-array", "rand_core 0.10.1", ] @@ -1099,6 +1102,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] diff --git a/crates/aura-crypto/Cargo.toml b/crates/aura-crypto/Cargo.toml index 669d019..60760fa 100644 --- a/crates/aura-crypto/Cargo.toml +++ b/crates/aura-crypto/Cargo.toml @@ -6,8 +6,14 @@ license.workspace = true description = "Aura cryptographic core: hybrid X25519 + ML-KEM-768 KEM, HKDF, ChaCha20-Poly1305" [dependencies] -ml-kem.workspace = true -x25519-dalek.workspace = true +# `getrandom`: enables ML-KEM's no-argument `generate_keypair()` / `encapsulate()` which use the +# system RNG internally. This avoids a rand_core major-version mismatch (this crate uses +# rand_core 0.6, while ml-kem's kem/crypto-common stack uses rand_core 0.10), so we never have to +# hand ml-kem an RNG handle. Feature is additive on top of the workspace's ["zeroize"]. +ml-kem = { workspace = true, features = ["getrandom"] } +# `zeroize`: makes x25519_dalek::StaticSecret wipe itself on drop. Additive on top of the +# workspace's ["static_secrets"]. +x25519-dalek = { workspace = true, features = ["zeroize"] } hkdf.workspace = true hmac.workspace = true sha2.workspace = true @@ -21,3 +27,7 @@ thiserror.workspace = true [dev-dependencies] hex.workspace = true criterion.workspace = true + +[[bench]] +name = "crypto" +harness = false diff --git a/crates/aura-crypto/benches/crypto.rs b/crates/aura-crypto/benches/crypto.rs new file mode 100644 index 0000000..70390b2 --- /dev/null +++ b/crates/aura-crypto/benches/crypto.rs @@ -0,0 +1,96 @@ +//! Criterion micro-benchmarks for the `aura-crypto` primitives. +//! +//! Run with `cargo bench -p aura-crypto`. Benches are compile-checked in CI via +//! `cargo bench --no-run -p aura-crypto`. + +use std::hint::black_box; + +use criterion::{criterion_group, criterion_main, Criterion, Throughput}; + +use aura_crypto::kem::{kyber, x25519}; +use aura_crypto::{derive_session_keys, AeadSession, HybridPrivateKey}; + +fn bench_kyber768_keygen(c: &mut Criterion) { + c.bench_function("kyber768_keygen", |b| { + b.iter(|| black_box(kyber::generate())); + }); +} + +fn bench_kyber768_encaps(c: &mut Criterion) { + let kp = kyber::generate(); + c.bench_function("kyber768_encaps", |b| { + b.iter(|| black_box(kyber::encapsulate(black_box(&kp.ek)).expect("encaps"))); + }); +} + +fn bench_kyber768_decaps(c: &mut Criterion) { + let kp = kyber::generate(); + let (ct, _ss) = kyber::encapsulate(&kp.ek).expect("encaps"); + c.bench_function("kyber768_decaps", |b| { + b.iter(|| { + black_box(kyber::decapsulate(black_box(&kp.dk), black_box(&ct)).expect("decaps")) + }); + }); +} + +fn bench_x25519(c: &mut Criterion) { + // Full ephemeral-static ECDH: keygen + diffie_hellman, representative of one handshake half. + let server_secret = x25519::generate_secret(); + let server_public = x25519::public_bytes(&server_secret); + c.bench_function("x25519_dh", |b| { + b.iter(|| { + let eph = x25519::generate_secret(); + let ss = x25519::diffie_hellman(&eph, black_box(&server_public)); + black_box(ss) + }); + }); +} + +fn bench_hybrid_handshake(c: &mut Criterion) { + c.bench_function("hybrid_handshake", |b| { + b.iter(|| { + // Full hybrid handshake: generate + encapsulate + decapsulate + derive session keys. + let (private, public) = HybridPrivateKey::generate(); + let (ct, ss_server) = public.encapsulate(); + let ss_client = private.decapsulate(&ct).expect("decapsulate"); + let keys = derive_session_keys(&ss_client, &[0u8; 32], &[1u8; 32]); + black_box((ss_server, keys)) + }); + }); +} + +fn bench_aead_1kb(c: &mut Criterion) { + let data = vec![0xABu8; 1024]; + let aad = b"aura"; + let mut group = c.benchmark_group("aead_seal"); + group.throughput(Throughput::Bytes(data.len() as u64)); + group.bench_function("aead_1kb", |b| { + let mut session = AeadSession::new([0x11u8; 32]); + b.iter(|| black_box(session.seal(black_box(&data), aad))); + }); + group.finish(); +} + +fn bench_aead_64kb(c: &mut Criterion) { + let data = vec![0xCDu8; 64 * 1024]; + let aad = b"aura"; + let mut group = c.benchmark_group("aead_seal"); + group.throughput(Throughput::Bytes(data.len() as u64)); + group.bench_function("aead_64kb", |b| { + let mut session = AeadSession::new([0x22u8; 32]); + b.iter(|| black_box(session.seal(black_box(&data), aad))); + }); + group.finish(); +} + +criterion_group!( + benches, + bench_kyber768_keygen, + bench_kyber768_encaps, + bench_kyber768_decaps, + bench_x25519, + bench_hybrid_handshake, + bench_aead_1kb, + bench_aead_64kb, +); +criterion_main!(benches); diff --git a/crates/aura-crypto/src/aead.rs b/crates/aura-crypto/src/aead.rs new file mode 100644 index 0000000..e4da8a2 --- /dev/null +++ b/crates/aura-crypto/src/aead.rs @@ -0,0 +1,158 @@ +//! Authenticated encryption session built on ChaCha20-Poly1305. +//! +//! An [`AeadSession`] wraps a 256-bit key and a 64-bit message counter. Each [`AeadSession::seal`] +//! / [`AeadSession::open`] call derives a unique 96-bit nonce from the counter and then advances +//! it, so a single session never reuses a nonce (until the 2^64 counter wraps, which is +//! unreachable in practice). +//! +//! The two endpoints of a connection keep one session per direction (see +//! [`crate::SessionKeys`]). A sender's `seal` session and the matching receiver's `open` session +//! advance their counters in lockstep, so they stay aligned without transmitting the nonce. + +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; +use zeroize::Zeroize; + +use crate::CryptoError; + +/// A directional AEAD session: a key plus a monotonically increasing nonce counter. +pub struct AeadSession { + key: [u8; 32], + counter: u64, +} + +impl AeadSession { + /// Create a new session from a 256-bit key, starting at counter 0. + #[must_use] + pub fn new(key: [u8; 32]) -> Self { + Self { key, counter: 0 } + } + + /// Derive the 96-bit (12-byte) nonce for a given counter value. + /// + /// Layout: little-endian `u64` counter in bytes `[0..8]`, then four zero bytes in `[8..12]`. + /// Exposed (crate-internal) so tests can assert nonce uniqueness directly. + #[must_use] + pub(crate) fn nonce_for(counter: u64) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[..8].copy_from_slice(&counter.to_le_bytes()); + // bytes [8..12] stay zero + nonce + } + + /// Build the cipher instance for the current key. + fn cipher(&self) -> ChaCha20Poly1305 { + ChaCha20Poly1305::new(Key::from_slice(&self.key)) + } + + /// Encrypt `plaintext` with associated data `aad`, returning `ciphertext || tag`. + /// + /// Uses the current counter as the nonce and then increments it. + /// + /// # Panics + /// Panics if the 64-bit message counter overflows (after 2^64 messages on one key) or if the + /// underlying AEAD reports an error (which, for ChaCha20-Poly1305 encryption, only happens + /// when the plaintext exceeds the cipher's maximum supported length). + pub fn seal(&mut self, plaintext: &[u8], aad: &[u8]) -> Vec { + let nonce = Self::nonce_for(self.counter); + let ct = self + .cipher() + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: plaintext, + aad, + }, + ) + .expect("ChaCha20-Poly1305 encryption never fails for in-range plaintext"); + self.counter = self + .counter + .checked_add(1) + .expect("AEAD nonce counter overflow"); + ct + } + + /// Decrypt `ciphertext` (which must be `ciphertext || tag`) with associated data `aad`. + /// + /// Uses the current counter as the nonce and then increments it (symmetrically to + /// [`AeadSession::seal`]), so a paired seal/open pair of sessions stay aligned even across a + /// failed decryption. + /// + /// # Errors + /// Returns [`CryptoError::AeadDecrypt`] if authentication fails (tampered ciphertext, wrong + /// AAD, wrong key, or desynchronized counter). + pub fn open(&mut self, ciphertext: &[u8], aad: &[u8]) -> Result, CryptoError> { + let nonce = Self::nonce_for(self.counter); + let result = self.cipher().decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: ciphertext, + aad, + }, + ); + // Advance symmetrically to `seal`, regardless of success, to keep counters aligned. + self.counter = self + .counter + .checked_add(1) + .expect("AEAD nonce counter overflow"); + result.map_err(|_| CryptoError::AeadDecrypt) + } + + /// Current counter value (next nonce to be used). Test-only accessor. + #[cfg(test)] + #[must_use] + pub(crate) fn counter(&self) -> u64 { + self.counter + } +} + +impl Drop for AeadSession { + fn drop(&mut self) { + self.key.zeroize(); + } +} + +impl zeroize::ZeroizeOnDrop for AeadSession {} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn nonce_layout_is_le_counter_then_zeros() { + assert_eq!(AeadSession::nonce_for(0), [0u8; 12]); + + let mut expected = [0u8; 12]; + expected[0] = 1; + assert_eq!(AeadSession::nonce_for(1), expected); + + // 0x0102030405060708 little-endian in the first 8 bytes, zeros after. + let n = AeadSession::nonce_for(0x0807_0605_0403_0201); + assert_eq!(&n[..8], &[1, 2, 3, 4, 5, 6, 7, 8]); + assert_eq!(&n[8..], &[0, 0, 0, 0]); + } + + #[test] + fn counter_is_monotonic_per_seal() { + let mut s = AeadSession::new([0u8; 32]); + assert_eq!(s.counter(), 0); + for expected_next in 1..=64u64 { + let _ = s.seal(b"x", b""); + assert_eq!(s.counter(), expected_next); + } + } + + #[test] + fn nonces_are_distinct_over_10_000_counters() { + // Directly exercise the nonce derivation (the crate-internal, testable surface). + let mut seen: HashSet<[u8; 12]> = HashSet::with_capacity(10_000); + for c in 0..10_000u64 { + assert!( + seen.insert(AeadSession::nonce_for(c)), + "duplicate nonce at {c}" + ); + } + assert_eq!(seen.len(), 10_000); + } +} diff --git a/crates/aura-crypto/src/kdf.rs b/crates/aura-crypto/src/kdf.rs new file mode 100644 index 0000000..8740bab --- /dev/null +++ b/crates/aura-crypto/src/kdf.rs @@ -0,0 +1,76 @@ +//! Session-key derivation via HKDF-SHA256 (RFC 5869). +//! +//! Given a [`HybridSharedSecret`] and the two handshake nonces, [`derive_session_keys`] +//! deterministically derives a pair of directional 256-bit keys. + +use hkdf::Hkdf; +use sha2::Sha256; +use zeroize::Zeroize; + +use crate::kem::HybridSharedSecret; + +/// Domain-separation `info` string bound into the HKDF expansion. +const HKDF_INFO: &[u8] = b"aura-v1-session"; + +/// A pair of directional AEAD keys derived from a hybrid handshake. +#[derive(Clone)] +pub struct SessionKeys { + /// Key protecting client -> server traffic. + pub client_to_server: [u8; 32], + /// Key protecting server -> client traffic. + pub server_to_client: [u8; 32], +} + +impl Drop for SessionKeys { + fn drop(&mut self) { + self.client_to_server.zeroize(); + self.server_to_client.zeroize(); + } +} + +/// Derive directional session keys from a hybrid shared secret and the handshake nonces. +/// +/// The derivation is HKDF-SHA256: +/// +/// * `salt` = `client_nonce || server_nonce` (64 bytes) +/// * `IKM` = `x25519_ss || kyber_ss` (64 bytes) +/// * `info` = `b"aura-v1-session"`, expanded to 64 bytes +/// +/// The first 32 output bytes become [`SessionKeys::client_to_server`] and the next 32 become +/// [`SessionKeys::server_to_client`]. The function is fully deterministic in its inputs. +#[must_use] +pub fn derive_session_keys( + shared: &HybridSharedSecret, + client_nonce: &[u8; 32], + server_nonce: &[u8; 32], +) -> SessionKeys { + // salt = client_nonce || server_nonce + let mut salt = [0u8; 64]; + salt[..32].copy_from_slice(client_nonce); + salt[32..].copy_from_slice(server_nonce); + + // IKM = x25519_ss || kyber_ss + let mut ikm = Vec::with_capacity(shared.x25519_ss.len() + shared.kyber_ss.len()); + ikm.extend_from_slice(&shared.x25519_ss); + ikm.extend_from_slice(&shared.kyber_ss); + + let hk = Hkdf::::new(Some(&salt), &ikm); + let mut okm = [0u8; 64]; + hk.expand(HKDF_INFO, &mut okm) + .expect("64 bytes is a valid HKDF-SHA256 output length"); + + let mut client_to_server = [0u8; 32]; + let mut server_to_client = [0u8; 32]; + client_to_server.copy_from_slice(&okm[..32]); + server_to_client.copy_from_slice(&okm[32..]); + + // Wipe intermediate secret material. + ikm.zeroize(); + okm.zeroize(); + salt.zeroize(); + + SessionKeys { + client_to_server, + server_to_client, + } +} diff --git a/crates/aura-crypto/src/kem/hybrid.rs b/crates/aura-crypto/src/kem/hybrid.rs new file mode 100644 index 0000000..731424f --- /dev/null +++ b/crates/aura-crypto/src/kem/hybrid.rs @@ -0,0 +1,149 @@ +//! Hybrid X25519 + ML-KEM-768 key encapsulation. +//! +//! The hybrid construction runs a classical ephemeral-static X25519 ECDH *and* a post-quantum +//! ML-KEM-768 encapsulation in parallel. The combined shared secret is the concatenation of both +//! halves, so an attacker must break **both** primitives to recover the session key. This is the +//! standard "belt and suspenders" defense used during the post-quantum migration. +//! +//! ## Roles +//! +//! * The party that owns the long-term [`HybridPrivateKey`] (the *client*, in Aura) publishes its +//! [`HybridPublicKey`]. +//! * The peer (the *server*) calls [`HybridPublicKey::encapsulate`], obtaining a +//! [`HybridCiphertext`] to send back and a [`HybridSharedSecret`]. +//! * The client recovers the same secret via [`HybridPrivateKey::decapsulate`]. + +use zeroize::{Zeroize, ZeroizeOnDrop}; + +use crate::CryptoError; + +use super::{kyber, x25519}; + +/// Public half of a hybrid keypair: an X25519 public key plus an ML-KEM-768 encapsulation key. +#[derive(Clone, Debug)] +pub struct HybridPublicKey { + /// X25519 public key (32 bytes). + pub x25519: [u8; 32], + /// ML-KEM-768 encapsulation (public) key (1184 bytes). + pub kyber: Vec, +} + +/// Secret half of a hybrid keypair. +/// +/// The X25519 secret and the ML-KEM-768 decapsulation key are both zeroized on drop. +pub struct HybridPrivateKey { + /// X25519 long-term secret key. + pub x25519: x25519_dalek::StaticSecret, + /// ML-KEM-768 decapsulation (secret) key bytes (2400-byte expanded encoding). + pub kyber: Vec, +} + +// NOTE: We implement `Drop` / `ZeroizeOnDrop` by hand rather than `#[derive(ZeroizeOnDrop)]` +// (which the spec sketches) because `x25519_dalek::StaticSecret` does not implement the +// `ZeroizeOnDrop` *marker* trait that the derive macro requires of every field. With the +// x25519-dalek `zeroize` feature enabled, `StaticSecret` zeroizes itself in its own `Drop`; here +// we additionally zeroize the secret ML-KEM key bytes. The end guarantee is identical. +impl Drop for HybridPrivateKey { + fn drop(&mut self) { + self.kyber.zeroize(); + // `self.x25519` zeroizes itself via its own `Drop` impl. + } +} +impl ZeroizeOnDrop for HybridPrivateKey {} + +/// Hybrid ciphertext: the server's ephemeral X25519 public key plus an ML-KEM-768 ciphertext. +#[derive(Clone, Debug)] +pub struct HybridCiphertext { + /// Server's ephemeral X25519 public key (32 bytes). + pub x25519_ephemeral: [u8; 32], + /// ML-KEM-768 ciphertext (1088 bytes). + pub kyber_ciphertext: Vec, +} + +/// Combined hybrid shared secret. Both halves are zeroized on drop. +pub struct HybridSharedSecret { + /// X25519 ECDH shared secret (32 bytes). + pub x25519_ss: [u8; 32], + /// ML-KEM-768 shared secret (32 bytes). + pub kyber_ss: Vec, +} + +impl Drop for HybridSharedSecret { + fn drop(&mut self) { + self.x25519_ss.zeroize(); + self.kyber_ss.zeroize(); + } +} +impl ZeroizeOnDrop for HybridSharedSecret {} + +impl HybridPrivateKey { + /// Generate a fresh hybrid keypair (X25519 + ML-KEM-768) using the operating-system RNG. + #[must_use] + pub fn generate() -> (HybridPrivateKey, HybridPublicKey) { + let x_secret = x25519::generate_secret(); + let x_public = x25519::public_bytes(&x_secret); + + let kp = kyber::generate(); + + let private = HybridPrivateKey { + x25519: x_secret, + kyber: kp.dk, + }; + let public = HybridPublicKey { + x25519: x_public, + kyber: kp.ek, + }; + (private, public) + } + + /// Client side: recover the hybrid shared secret from the peer's [`HybridCiphertext`]. + /// + /// Combines an X25519 ECDH against the server's ephemeral public key with an ML-KEM-768 + /// decapsulation under this key's decapsulation key. + /// + /// # Errors + /// Returns [`CryptoError`] if the ML-KEM ciphertext or this key's stored decapsulation key is + /// malformed. + pub fn decapsulate(&self, ct: &HybridCiphertext) -> Result { + let x25519_ss = x25519::diffie_hellman(&self.x25519, &ct.x25519_ephemeral); + let kyber_ss = kyber::decapsulate(&self.kyber, &ct.kyber_ciphertext)?; + Ok(HybridSharedSecret { + x25519_ss, + kyber_ss: kyber_ss.to_vec(), + }) + } +} + +impl HybridPublicKey { + /// Server side: encapsulate to this public key. + /// + /// Generates an ephemeral X25519 keypair (deriving the ECDH secret against the peer's static + /// public key) and an ML-KEM-768 encapsulation. Returns the [`HybridCiphertext`] to send back + /// to the peer and the resulting [`HybridSharedSecret`]. + /// + /// # Panics + /// Panics only if `self.kyber` is not a valid 1184-byte ML-KEM-768 encapsulation key. A + /// `HybridPublicKey` produced by [`HybridPrivateKey::generate`] always satisfies this; the + /// panic guards against a hand-constructed, malformed public key. + #[must_use] + pub fn encapsulate(&self) -> (HybridCiphertext, HybridSharedSecret) { + // X25519: fresh ephemeral secret; ss = DH(ephemeral, peer_static_public). + let eph_secret = x25519::generate_secret(); + let eph_public = x25519::public_bytes(&eph_secret); + let x25519_ss = x25519::diffie_hellman(&eph_secret, &self.x25519); + + // ML-KEM: encapsulate against the peer's encapsulation key. + let (kyber_ct, kyber_ss) = + kyber::encapsulate(&self.kyber).expect("HybridPublicKey holds a valid ML-KEM-768 key"); + + let ciphertext = HybridCiphertext { + x25519_ephemeral: eph_public, + kyber_ciphertext: kyber_ct, + }; + let shared = HybridSharedSecret { + x25519_ss, + kyber_ss: kyber_ss.to_vec(), + }; + (ciphertext, shared) + } +} diff --git a/crates/aura-crypto/src/kem/kyber.rs b/crates/aura-crypto/src/kem/kyber.rs new file mode 100644 index 0000000..63596eb --- /dev/null +++ b/crates/aura-crypto/src/kem/kyber.rs @@ -0,0 +1,124 @@ +//! ML-KEM-768 (FIPS 203) wrapper built on the [`ml_kem`] crate (v0.3). +//! +//! This module hides the `ml_kem` / `hybrid_array` generics behind a small, byte-oriented +//! surface that the hybrid KEM uses. All public functions speak in plain `Vec` / `[u8; 32]`. +//! +//! ## Serialization choices +//! +//! * The **encapsulation key** (public) is serialized in its standard 1184-byte form. +//! * The **decapsulation key** (secret) is serialized in the FIPS 203 *expanded* 2400-byte +//! form. `ml_kem` 0.3 prefers a compact 64-byte seed, but the project spec (and external +//! ACVP/FIPS-203 known-answer test vectors) operate on the 2400-byte expanded encoding, so we +//! use that here via [`ml_kem::ExpandedKeyEncoding`]. That trait is `#[deprecated]` upstream on +//! stylistic grounds; it remains the only way to round-trip the standardized 2400-byte `dk` +//! encoding, which interop and KATs require. + +#![allow(deprecated)] // ExpandedKeyEncoding is the canonical 2400-byte dk encoding (see module docs). + +use ml_kem::array::Array; +use ml_kem::kem::{Decapsulate, Encapsulate, Kem, KeyExport, TryKeyInit}; +use ml_kem::{EncapsulationKey, ExpandedKeyEncoding, MlKem768}; + +use crate::CryptoError; + +/// Concrete ML-KEM-768 encapsulation (public) key type. +type Ek = EncapsulationKey; +/// Concrete ML-KEM-768 decapsulation (secret) key type. +type Dk = ::DecapsulationKey; + +/// Size in bytes of a serialized ML-KEM-768 encapsulation (public) key. +pub const EK_LEN: usize = 1184; +/// Size in bytes of a serialized ML-KEM-768 decapsulation (secret) key (expanded form). +pub const DK_LEN: usize = 2400; +/// Size in bytes of an ML-KEM-768 ciphertext. +pub const CT_LEN: usize = 1088; +/// Size in bytes of an ML-KEM shared secret. +pub const SS_LEN: usize = 32; + +/// A freshly generated ML-KEM-768 keypair, serialized to bytes. +pub struct KyberKeypair { + /// Decapsulation (secret) key, 2400-byte expanded encoding. + pub dk: Vec, + /// Encapsulation (public) key, 1184 bytes. + pub ek: Vec, +} + +/// Generate a fresh ML-KEM-768 keypair using the operating-system RNG. +#[must_use] +pub fn generate() -> KyberKeypair { + // Use the no-argument constructor (backed by the system RNG via the `getrandom` feature). + // This sidesteps a `rand_core` major-version mismatch between this crate (0.6) and + // `ml_kem`'s `kem`/`crypto-common` stack (0.10). + let (dk, ek) = MlKem768::generate_keypair(); + KyberKeypair { + dk: dk.to_expanded_bytes().to_vec(), + ek: ek.to_bytes().to_vec(), + } +} + +/// Encapsulate against a serialized encapsulation key. +/// +/// Returns `(ciphertext, shared_secret)`. Uses the OS RNG internally. +/// +/// # Errors +/// Returns [`CryptoError`] if `ek_bytes` is not a valid 1184-byte ML-KEM-768 encapsulation key. +pub fn encapsulate(ek_bytes: &[u8]) -> Result<(Vec, [u8; SS_LEN]), CryptoError> { + let ek = decode_ek(ek_bytes)?; + let (ct, ss) = ek.encapsulate(); + Ok((ct.to_vec(), to_ss(&ss))) +} + +/// Decapsulate a ciphertext using a serialized decapsulation key. +/// +/// # Errors +/// Returns [`CryptoError`] if `dk_bytes` is not a valid 2400-byte expanded decapsulation key +/// or if `ct_bytes` is not a valid 1088-byte ciphertext. +pub fn decapsulate(dk_bytes: &[u8], ct_bytes: &[u8]) -> Result<[u8; SS_LEN], CryptoError> { + let dk = decode_dk(dk_bytes)?; + if ct_bytes.len() != CT_LEN { + return Err(CryptoError::InvalidLength { + what: "kyber_ciphertext", + expected: CT_LEN, + got: ct_bytes.len(), + }); + } + // `decapsulate_slice` validates the ciphertext length against the parameter set. ML-KEM + // decapsulation is infallible on a correctly sized ciphertext (implicit rejection yields a + // pseudo-random secret rather than an error on a tampered ciphertext). + let ss = dk + .decapsulate_slice(ct_bytes) + .map_err(|_| CryptoError::KyberDecode("ciphertext length"))?; + Ok(to_ss(&ss)) +} + +/// Decode a serialized 1184-byte encapsulation key. +fn decode_ek(ek_bytes: &[u8]) -> Result { + if ek_bytes.len() != EK_LEN { + return Err(CryptoError::InvalidLength { + what: "kyber_ek", + expected: EK_LEN, + got: ek_bytes.len(), + }); + } + Ek::new_from_slice(ek_bytes).map_err(|_| CryptoError::KyberDecode("invalid ek")) +} + +/// Decode a serialized 2400-byte expanded decapsulation key. +fn decode_dk(dk_bytes: &[u8]) -> Result { + if dk_bytes.len() != DK_LEN { + return Err(CryptoError::InvalidLength { + what: "kyber_dk", + expected: DK_LEN, + got: dk_bytes.len(), + }); + } + let encoded = Array::try_from(dk_bytes).map_err(|_| CryptoError::KyberDecode("dk length"))?; + Dk::from_expanded_bytes(&encoded).map_err(|_| CryptoError::KyberDecode("invalid dk")) +} + +/// Convert an `ml_kem` shared key (32-byte `Array`) into a fixed array. +fn to_ss(ss: &ml_kem::SharedKey) -> [u8; SS_LEN] { + let mut out = [0u8; SS_LEN]; + out.copy_from_slice(ss.as_slice()); + out +} diff --git a/crates/aura-crypto/src/kem/mod.rs b/crates/aura-crypto/src/kem/mod.rs new file mode 100644 index 0000000..f531056 --- /dev/null +++ b/crates/aura-crypto/src/kem/mod.rs @@ -0,0 +1,17 @@ +//! Hybrid key encapsulation mechanism (X25519 + ML-KEM-768) and its building blocks. +//! +//! The public surface lives in [`hybrid`]; the [`x25519`] and [`kyber`] submodules provide the +//! classical and post-quantum halves respectively. + +pub mod hybrid; +pub mod kyber; +pub mod x25519; + +pub use hybrid::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret}; + +/// ML-KEM-768 byte-length constants, re-exported for convenience and for use in tests. +pub mod sizes { + pub use super::kyber::{CT_LEN, DK_LEN, EK_LEN, SS_LEN}; + /// Length of an X25519 public key / shared secret in bytes. + pub use super::x25519::X25519_LEN; +} diff --git a/crates/aura-crypto/src/kem/x25519.rs b/crates/aura-crypto/src/kem/x25519.rs new file mode 100644 index 0000000..0b198ed --- /dev/null +++ b/crates/aura-crypto/src/kem/x25519.rs @@ -0,0 +1,30 @@ +//! X25519 Elliptic-Curve Diffie-Hellman helpers. +//! +//! Thin wrappers over [`x25519_dalek`] used by the hybrid KEM. The classic ECDH half of the +//! hybrid construction provides security against a classical adversary even if the post-quantum +//! KEM is ever broken, and vice versa. + +use rand::rngs::OsRng; +use x25519_dalek::{PublicKey, StaticSecret}; + +/// Length of an X25519 public key / shared secret in bytes. +pub const X25519_LEN: usize = 32; + +/// Generate a fresh X25519 secret key using the operating-system RNG. +#[must_use] +pub fn generate_secret() -> StaticSecret { + StaticSecret::random_from_rng(OsRng) +} + +/// Return the public key bytes for a secret key. +#[must_use] +pub fn public_bytes(secret: &StaticSecret) -> [u8; X25519_LEN] { + PublicKey::from(secret).to_bytes() +} + +/// Compute the raw ECDH shared secret between `secret` and a peer public key (given as bytes). +#[must_use] +pub fn diffie_hellman(secret: &StaticSecret, peer_public: &[u8; X25519_LEN]) -> [u8; X25519_LEN] { + let peer = PublicKey::from(*peer_public); + secret.diffie_hellman(&peer).to_bytes() +} diff --git a/crates/aura-crypto/src/lib.rs b/crates/aura-crypto/src/lib.rs index a78231b..156d138 100644 --- a/crates/aura-crypto/src/lib.rs +++ b/crates/aura-crypto/src/lib.rs @@ -1 +1,46 @@ -//! aura-crypto — cryptographic core (skeleton; implemented in Wave 1). +//! aura-crypto — cryptographic core for the Aura hybrid post-quantum VPN. +//! +//! This crate provides: +//! +//! * A **hybrid KEM** combining X25519 ECDH with ML-KEM-768 (FIPS 203), see [`kem`]. +//! * **HKDF-SHA256** based session-key derivation, see [`derive_session_keys`]. +//! * An **AEAD session** built on ChaCha20-Poly1305 with a counter nonce, see [`AeadSession`]. +//! +//! All secret-bearing types ([`HybridPrivateKey`], [`HybridSharedSecret`], [`AeadSession`]) +//! zeroize their secrets on drop. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +pub mod aead; +pub mod kdf; +pub mod kem; + +pub use aead::AeadSession; +pub use kdf::{derive_session_keys, SessionKeys}; +pub use kem::{HybridCiphertext, HybridPrivateKey, HybridPublicKey, HybridSharedSecret}; + +use thiserror::Error; + +/// Errors returned by the `aura-crypto` primitives. +#[derive(Debug, Error)] +pub enum CryptoError { + /// A supplied key/ciphertext/shared-secret had an unexpected length. + #[error("invalid length for {what}: expected {expected}, got {got}")] + InvalidLength { + /// Name of the field whose length was wrong. + what: &'static str, + /// Expected length in bytes. + expected: usize, + /// Actual length in bytes. + got: usize, + }, + + /// An ML-KEM key or ciphertext failed to decode / validate. + #[error("ML-KEM decode error: {0}")] + KyberDecode(&'static str), + + /// AEAD decryption failed (authentication tag mismatch or malformed input). + #[error("AEAD decryption failed")] + AeadDecrypt, +} diff --git a/crates/aura-crypto/tests/hybrid_kat.rs b/crates/aura-crypto/tests/hybrid_kat.rs new file mode 100644 index 0000000..8da9465 --- /dev/null +++ b/crates/aura-crypto/tests/hybrid_kat.rs @@ -0,0 +1,249 @@ +//! Integration tests for the hybrid KEM, HKDF key derivation, and AEAD session. +//! +//! These exercise the public API exactly as a downstream crate (e.g. the Aura handshake) would. + +use aura_crypto::{derive_session_keys, AeadSession, HybridPrivateKey, HybridSharedSecret}; + +/// keygen -> encapsulate -> decapsulate; both shared-secret halves must agree. +#[test] +fn test_hybrid_roundtrip() { + let (private, public) = HybridPrivateKey::generate(); + + let (ciphertext, ss_server) = public.encapsulate(); + let ss_client = private + .decapsulate(&ciphertext) + .expect("decapsulation succeeds"); + + assert_eq!( + ss_server.x25519_ss, ss_client.x25519_ss, + "x25519 shared secret halves must match" + ); + assert_eq!( + ss_server.kyber_ss, ss_client.kyber_ss, + "ML-KEM shared secret halves must match" + ); + // Sanity: the two halves are independent 32-byte values. + assert_eq!(ss_server.x25519_ss.len(), 32); + assert_eq!(ss_server.kyber_ss.len(), 32); +} + +/// Run the hybrid roundtrip many times to catch any rare encode/decode mismatch. +#[test] +fn test_hybrid_roundtrip_property() { + for _ in 0..50 { + let (private, public) = HybridPrivateKey::generate(); + let (ct, ss_server) = public.encapsulate(); + let ss_client = private.decapsulate(&ct).expect("decapsulation succeeds"); + assert_eq!(ss_server.x25519_ss, ss_client.x25519_ss); + assert_eq!(ss_server.kyber_ss, ss_client.kyber_ss); + } +} + +/// Two independent keypairs must (overwhelmingly) not produce colliding ciphertexts/secrets, and +/// decapsulating someone else's ciphertext with the wrong key must NOT yield the server's secret. +#[test] +fn test_hybrid_wrong_key_disagrees() { + let (private_a, _public_a) = HybridPrivateKey::generate(); + let (_private_b, public_b) = HybridPrivateKey::generate(); + + // Encapsulate to B, then try to decapsulate with A's key. + let (ct_for_b, ss_b) = public_b.encapsulate(); + let ss_a = private_a + .decapsulate(&ct_for_b) + .expect("decapsulation is infallible on a well-formed ciphertext"); + + // x25519 half must differ (different static keys). + assert_ne!(ss_a.x25519_ss, ss_b.x25519_ss); + // ML-KEM half differs too: implicit rejection yields an unrelated pseudo-random secret. + assert_ne!(ss_a.kyber_ss, ss_b.kyber_ss); +} + +/// Helper: build a `HybridSharedSecret` from raw halves for KDF tests. +fn shared_from(x: [u8; 32], k: [u8; 32]) -> HybridSharedSecret { + // We cannot construct `HybridSharedSecret` field-by-field from outside via a constructor, but + // its fields are public, so build it directly. + HybridSharedSecret { + x25519_ss: x, + kyber_ss: k.to_vec(), + } +} + +/// Same (shared, nonces) -> identical keys; changing a nonce -> different keys. +#[test] +fn test_kdf_deterministic() { + let shared = shared_from([7u8; 32], [9u8; 32]); + let client_nonce = [1u8; 32]; + let server_nonce = [2u8; 32]; + + let k1 = derive_session_keys(&shared, &client_nonce, &server_nonce); + let k2 = derive_session_keys(&shared, &client_nonce, &server_nonce); + + assert_eq!(k1.client_to_server, k2.client_to_server); + assert_eq!(k1.server_to_client, k2.server_to_client); + + // The two directional keys are distinct (different halves of the HKDF output). + assert_ne!(k1.client_to_server, k1.server_to_client); + + // Changing the client nonce changes the keys. + let mut other_client = client_nonce; + other_client[0] ^= 0xFF; + let k3 = derive_session_keys(&shared, &other_client, &server_nonce); + assert_ne!(k1.client_to_server, k3.client_to_server); + assert_ne!(k1.server_to_client, k3.server_to_client); + + // Changing the server nonce changes the keys. + let mut other_server = server_nonce; + other_server[31] ^= 0x01; + let k4 = derive_session_keys(&shared, &client_nonce, &other_server); + assert_ne!(k1.client_to_server, k4.client_to_server); + + // Changing the shared secret changes the keys. + let shared2 = shared_from([8u8; 32], [9u8; 32]); + let k5 = derive_session_keys(&shared2, &client_nonce, &server_nonce); + assert_ne!(k1.client_to_server, k5.client_to_server); +} + +/// End-to-end: derive keys from a real handshake, then check the directional keys actually +/// protect traffic. +#[test] +fn test_kdf_from_real_handshake() { + let (private, public) = HybridPrivateKey::generate(); + let (ct, ss_server) = public.encapsulate(); + let ss_client = private.decapsulate(&ct).expect("decapsulate"); + + let client_nonce = [0x11u8; 32]; + let server_nonce = [0x22u8; 32]; + + let server_keys = derive_session_keys(&ss_server, &client_nonce, &server_nonce); + let client_keys = derive_session_keys(&ss_client, &client_nonce, &server_nonce); + + // Both sides derive the same key material. + assert_eq!(server_keys.client_to_server, client_keys.client_to_server); + assert_eq!(server_keys.server_to_client, client_keys.server_to_client); +} + +/// seal then open returns the plaintext when AAD matches. +#[test] +fn test_aead_roundtrip() { + let key = [0x42u8; 32]; + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + + let plaintext = b"hybrid post-quantum VPN payload"; + let aad = b"aura-header-v1"; + + let ct = sender.seal(plaintext, aad); + // Ciphertext is plaintext length + 16-byte Poly1305 tag. + assert_eq!(ct.len(), plaintext.len() + 16); + + let recovered = receiver + .open(&ct, aad) + .expect("open succeeds with matching AAD"); + assert_eq!(recovered, plaintext); +} + +/// Multiple sequential messages stay aligned between a sender and receiver session. +#[test] +fn test_aead_sequential_messages() { + let key = [0x01u8; 32]; + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + + for i in 0u32..100 { + let msg = format!("message number {i}"); + let aad = i.to_le_bytes(); + let ct = sender.seal(msg.as_bytes(), &aad); + let pt = receiver.open(&ct, &aad).expect("aligned open succeeds"); + assert_eq!(pt, msg.as_bytes()); + } +} + +/// Flipping a ciphertext byte, changing AAD, or using the wrong key must fail authentication. +#[test] +fn test_aead_tamper_detection() { + let key = [0x42u8; 32]; + let aad = b"aura-header-v1"; + let plaintext = b"top secret"; + + // 1. Flip a ciphertext byte. + { + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + let mut ct = sender.seal(plaintext, aad); + ct[0] ^= 0x01; + assert!( + receiver.open(&ct, aad).is_err(), + "tampered ciphertext must fail" + ); + } + + // 2. Flip a tag byte (last byte). + { + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + let mut ct = sender.seal(plaintext, aad); + let last = ct.len() - 1; + ct[last] ^= 0x80; + assert!(receiver.open(&ct, aad).is_err(), "tampered tag must fail"); + } + + // 3. Change the AAD. + { + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + let ct = sender.seal(plaintext, aad); + assert!( + receiver.open(&ct, b"different-aad").is_err(), + "mismatched AAD must fail" + ); + } + + // 4. Wrong key. + { + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new([0x00u8; 32]); + let ct = sender.seal(plaintext, aad); + assert!(receiver.open(&ct, aad).is_err(), "wrong key must fail"); + } +} + +/// A failed `open` still advances the counter, keeping a (sender, receiver) pair aligned for the +/// next message (so a single dropped/tampered frame does not desynchronize the stream here). +#[test] +fn test_aead_counter_advances_on_failure() { + let key = [0x55u8; 32]; + let mut sender = AeadSession::new(key); + let mut receiver = AeadSession::new(key); + + // Message 0: tamper -> fails, but receiver counter advances to 1. + let mut ct0 = sender.seal(b"first", b"a"); + ct0[0] ^= 0x01; + assert!(receiver.open(&ct0, b"a").is_err()); + + // Message 1: both at counter 1 now -> succeeds. + let ct1 = sender.seal(b"second", b"b"); + let pt1 = receiver.open(&ct1, b"b").expect("counters re-aligned at 1"); + assert_eq!(pt1, b"second"); +} + +/// 10_000 seal calls must use 10_000 distinct nonces. We verify this behaviorally: encrypting the +/// *same* plaintext+AAD under the same key 10_000 times yields 10_000 distinct ciphertexts, which +/// can only happen if the (counter-derived) nonce never repeats. +#[test] +fn test_nonce_no_repeat() { + use std::collections::HashSet; + + let mut session = AeadSession::new([0x7Au8; 32]); + let plaintext = b"constant"; + let aad = b"constant-aad"; + + let mut seen: HashSet> = HashSet::with_capacity(10_000); + for _ in 0..10_000 { + let ct = session.seal(plaintext, aad); + assert!( + seen.insert(ct), + "nonce reuse produced a duplicate ciphertext" + ); + } + assert_eq!(seen.len(), 10_000); +} diff --git a/crates/aura-crypto/tests/kat_kyber.rs b/crates/aura-crypto/tests/kat_kyber.rs new file mode 100644 index 0000000..2a3a5cb --- /dev/null +++ b/crates/aura-crypto/tests/kat_kyber.rs @@ -0,0 +1,72 @@ +//! ML-KEM-768 (FIPS 203) Known-Answer Test. +//! +//! This is a **real** KAT, not merely a roundtrip. The vector below is an official NIST ACVP +//! ML-KEM ("encapDecap") sample vector: +//! +//! * Source: , +//! `gen-val/json-files/ML-KEM-encapDecap-FIPS203/internalProjection.json` +//! * `vsId = 42`, ML-KEM-768 AFT, `tcId = 26` (`isSample: true`). +//! +//! The decapsulation key (`DK`, 2400-byte FIPS 203 expanded form) and ciphertext (`CT`, 1088 +//! bytes) are fed into our wrapper; the recovered shared secret must equal the expected `K`. +//! ML-KEM decapsulation is deterministic, so this needs no RNG and pins exact bytes. +//! +//! We also assert the canonical ML-KEM-768 sizes (ek = 1184, dk = 2400, ct = 1088, ss = 32) and +//! perform a fresh keygen/encapsulate/decapsulate roundtrip. + +use aura_crypto::kem::kyber; +use aura_crypto::kem::sizes::{CT_LEN, DK_LEN, EK_LEN, SS_LEN}; + +// NIST ACVP ML-KEM-768 vector (vsId 42, tcId 26). +const KAT_EK_HEX: &str = "b649b9ad5a59aa45640b03ace153499bc1244465735dca6e5ed0c7116070287758e7a31ee53ba171e7c8964b3615075286a4af1ea12479ab0218608692a2606a024d12fcae691c8114828f3547c9d0344af9920d952ba6bce6aae6a47360da1588697f91ab5475c5588ad6328389a34ba50e41514343c534ad7947c5aa4220c73d335bb24f6676cc2549fd40759cd4b54549b04d8932921b183ecb634b579a54742dd6734c7225741ba32ac196aa68faaf3d1425d4a44cc563aaf8816a8258bf745842f1ca7d8eda9a7a6ccd72966abab9061ee21ef3d2b1155133f4b8099b653ba8b5224360cf00295f2b3887d1b12d601b18bd407b80d167aefa0d3f6a906fc2cd08a663b7766815a26c6e2bc83318ac99b5a56d338ec347adbd9a57ec53359ce898fb637b32fc4a6fc216bfa30eec501681751bee46c5c02317c3b3b98f24ac67acc53941cd20035fe2a59890e9ab7cf063fe07a62703643e0580d99c152343c5bdd8cb9f9c1fd0c194ee7281913a7d1f0473722c024df76568a731d309cd5fa87fb3a0c771aa42efd160af89752c1c3eeac74a934b163af92d4ee74c709a31e901045fe6202de9622b552acd807829f46ad9c47087e2856f294b97546103568292a4b7462895f161891af4a66d537e79087f87f63e4e5a7d767a5d6a4a52267c8ce41413ef6c3dc4b1c64ee5ad75d9542099361ef81246a64ad997885fe0631d02919ab6b967b8c441d73b67d52b5fa64ac7789d30e659d776334da3a65a3b4081014455bc858637b23a991fe8ec315c687c36d81553c79f159c2b4b285604c0541ab62749cba6c29472b5dc6ab61b2be2e6a57a1942e729c1e95ba95c8100d4554fcedc0d73ac8023f736a94ac757b7b5108807a5eaba507b6f22e627ef325c0ef3b28123be7882840b7a8efcba7e0d82434c330b37b7c7f546b123d460a0d0c58893a7e4664f49acc9150a5dfbb71fbef44374a987e3192be4a50fc1f1160a0488844864532689e9f29d55366969e014b19869251977c34049437bb41b334c2de7a2eb63cc3ff21b042aa0e6839469e4bfa226cdbf8331cd1640e04b4cf2a89bffc20283dc2d90706604c1021153417b26c650b483856463df2c2c064ab4a9f316c5ba02109b1023370dded31ab1da2eb837bd8ccc52106712eb91a019119bd60951b3662f3f6291ecb76561b253dc4a1cb8e41b3a16b2ec87a252c4b747448823902845527b31c15a3ef18e174b644f548faf30b3da5610edccb73e3a8714bbbdd668c14a9472718b34efc545fff2783f033f13fc1665bc324ca244f1e91851d8ce2df2b388ea24b2cb8eab400f5a8ac1d01442f765688393ce21c4c63113ba49480b247c3fb4d49df82b1f493430bfa78f6d948da4e927bdd9bd2d18a7f230046853bd8be51cd59178d0295509213b7e1b0798584dce835b48312f0257a185d9360e0a702ad8bb0a53c119336889974b8e52b636328556ca1a9eec413f5259c66503c90206a7857925c727815c94fd545f0112c6a7e89c2ef54ae897a4b0792f98f5710ca174288658f5c8596c7807008369831135e1d50d5ac77f6ae9641de0622bca6a8e746700818c4a22a9ad30c9bc660117f3462617baf392280de09f5695b3cdda5e931c5b521bdaa455c3d0f0f7375153a754ed9620da68dd"; +const KAT_DK_HEX: &str = "0af8cf210b1b442c963feb4297f3a1c0513353f6808c8450b3c698a0f75034f9349591201c674fb835c6f9ba484376b15abc27a0443ca7b293602321717ca9ec4411d8a184032289b6398e07c34d46e05d8a10cf51984486fc90c5ebb08d23a740db30e6c88e2cb0ba758590b413a3ea50583d885945053b66631547405f59a053fb6b9e57e2b7d9593d71615eab21b78774b32a6c9fbcc3b845e48130d86aacbc6900295dbfab17e9f532f6635b45216fc71727b9f2c67ee5619691bec4a1b2baa990445978eda08a6ab0279460bd6a7590c66044079c8728a68a11a1469f8139572572e3e2b1a8a4226e9bafe8f871e2c21695b20aa15bcb56a974b582cfc168838e75226717b398cc5b3da780d4c5310f35c37d2376f773a06ac0c2d8e524ec50abc6d72e18f509027c3dc6d47dc7b28e52f779094458c8948623610ff9c045b865885e08a8d946235fe053b9e3042b152fc5a318b6b785d9919bb030467ac775c9eb56fa00135b3c5b7e14bb5522a281598d584425c0234f358a713e581efb5a2ce1560d53a36d48a8330e18c6c99bb7018275445bcf72c3a6a8db27f90455dea48f7858978004b672094f1ceb700c284205864dbbd61c5f68b59df95e8b01258f630e2f302919e10551400602656ccba21fb931c365ea6b79936d7743b59763433e24a337cc2443a067c5733205189c38502399fc2ca7b360050555d4f719ea677d06b134cbf35559784a5727629ad888ebf37d57e012de56b9978b8ccda395b2ac4b56443ad22053f382b409088851c2146aea2d4105d091fb91a5f63fbebcc7bca382b2046f47e20119506157ab9a2104a62ad82e100c8a99093b4f787809491268e19fed6ab4436a513294419dc5322e076b686719cda59b891462ffe246d338767ee52a14e87a3ba99142291c62d42bba2697e72b810d335e4d7987cb208ec2aa466d09bacaf407b62a6a71442edabba9ffe03cefeb8ae84abf0fac3641c79479f67b6d04c98458a2cae7cd817b6acfa4b0c4a3a85104b610b746adb75d817a9c104bb2d29bc9b0a948fe4682f1416bfe44000290ab22319ec0a531a89a9a5fb544ef72ce93b1cd0bc3056ac768e4c50804cb3db4c2372f506d12649776175d127bb5edb7c2d79a01e304a7acb76f164c2143f6056feacf9690700fe15eb2fac559646c765c708248698218404080c0f1a35e7ec44969875a301ca59000289a685a31cc69e5f2285f9a260bdb8a1cb13b4467b98f782051dcb8f5e91889581277a359bd279905abcf9a1a452507b810171865821fc7fbcb7c0ab2fe651706c4478323acf69a1f2fdc539b547b25bc9302d9c6272ab027852c1ff4c8b33357cd77730ad6160aab68eda2b532d8bc7c4061594044478569e1d7238d464f66b141d5cc3d9b67a7cf554dd4460d60265f8c0b5899b68d57073a034b0e0b6cb9391ab9a8ea75a0e19d86125b34ab885885839f726548d54d954061411801f5651f780cb74ddba0471c33319772b8661acaa9c6dab97dd31a684288c84dc56f3193a8e2d7cb29d74044321e77f3a0b1c27cb056c16161a9e2d417e4020e990bc9ea3abef2101d4d89ad5f390b2c81cb221429ce796f7dc890c0807008bcc0b649b9ad5a59aa45640b03ace153499bc1244465735dca6e5ed0c7116070287758e7a31ee53ba171e7c8964b3615075286a4af1ea12479ab0218608692a2606a024d12fcae691c8114828f3547c9d0344af9920d952ba6bce6aae6a47360da1588697f91ab5475c5588ad6328389a34ba50e41514343c534ad7947c5aa4220c73d335bb24f6676cc2549fd40759cd4b54549b04d8932921b183ecb634b579a54742dd6734c7225741ba32ac196aa68faaf3d1425d4a44cc563aaf8816a8258bf745842f1ca7d8eda9a7a6ccd72966abab9061ee21ef3d2b1155133f4b8099b653ba8b5224360cf00295f2b3887d1b12d601b18bd407b80d167aefa0d3f6a906fc2cd08a663b7766815a26c6e2bc83318ac99b5a56d338ec347adbd9a57ec53359ce898fb637b32fc4a6fc216bfa30eec501681751bee46c5c02317c3b3b98f24ac67acc53941cd20035fe2a59890e9ab7cf063fe07a62703643e0580d99c152343c5bdd8cb9f9c1fd0c194ee7281913a7d1f0473722c024df76568a731d309cd5fa87fb3a0c771aa42efd160af89752c1c3eeac74a934b163af92d4ee74c709a31e901045fe6202de9622b552acd807829f46ad9c47087e2856f294b97546103568292a4b7462895f161891af4a66d537e79087f87f63e4e5a7d767a5d6a4a52267c8ce41413ef6c3dc4b1c64ee5ad75d9542099361ef81246a64ad997885fe0631d02919ab6b967b8c441d73b67d52b5fa64ac7789d30e659d776334da3a65a3b4081014455bc858637b23a991fe8ec315c687c36d81553c79f159c2b4b285604c0541ab62749cba6c29472b5dc6ab61b2be2e6a57a1942e729c1e95ba95c8100d4554fcedc0d73ac8023f736a94ac757b7b5108807a5eaba507b6f22e627ef325c0ef3b28123be7882840b7a8efcba7e0d82434c330b37b7c7f546b123d460a0d0c58893a7e4664f49acc9150a5dfbb71fbef44374a987e3192be4a50fc1f1160a0488844864532689e9f29d55366969e014b19869251977c34049437bb41b334c2de7a2eb63cc3ff21b042aa0e6839469e4bfa226cdbf8331cd1640e04b4cf2a89bffc20283dc2d90706604c1021153417b26c650b483856463df2c2c064ab4a9f316c5ba02109b1023370dded31ab1da2eb837bd8ccc52106712eb91a019119bd60951b3662f3f6291ecb76561b253dc4a1cb8e41b3a16b2ec87a252c4b747448823902845527b31c15a3ef18e174b644f548faf30b3da5610edccb73e3a8714bbbdd668c14a9472718b34efc545fff2783f033f13fc1665bc324ca244f1e91851d8ce2df2b388ea24b2cb8eab400f5a8ac1d01442f765688393ce21c4c63113ba49480b247c3fb4d49df82b1f493430bfa78f6d948da4e927bdd9bd2d18a7f230046853bd8be51cd59178d0295509213b7e1b0798584dce835b48312f0257a185d9360e0a702ad8bb0a53c119336889974b8e52b636328556ca1a9eec413f5259c66503c90206a7857925c727815c94fd545f0112c6a7e89c2ef54ae897a4b0792f98f5710ca174288658f5c8596c7807008369831135e1d50d5ac77f6ae9641de0622bca6a8e746700818c4a22a9ad30c9bc660117f3462617baf392280de09f5695b3cdda5e931c5b521bdaa455c3d0f0f7375153a754ed9620da68dd6d0d1469801b55e3aee59aa34b9097e964bf39a8c8ea9526289e5f19d213e6bd6294966bbadd4259c7036c078207214ba15e55120960c4191162722b5e781907"; +const KAT_CT_HEX: &str = "04f4a18c69708a17f561778b2ac10d94380abea4a20835939c9015d78dac41a5012ced1bed948aed6c79193f8b2fc6deabd3b092ec33ae2f54778f1c54ce762a69521764e20c05bc2ef96992f463ca95d09dd588af622c297bbd8805113e985388fc9e16fda06b5eed42da629d514f86ed84acff0a09418e720201b794b49d072df15e7b7d6ec6d82379a212c71c7603a1c9bbe57fb1cb9a431de1980ecada0a4fbf5cace9ad0ceedbfdc40761839d9cc1c8590eb6335179075892a8015e04ecadad37fdcd4644ec2284cf4cbb4620fbab6055a163e3733e3a7747044b766ebc356436b33e28fa4e67b083592b05811361445c719f6ae8add4ef8ce145e3933cee75d19e98bb964d58044b6de2b46107f80c3d4690114cc84fb0d3b3d4c3af671ea7b833746b54fce5cc761ca4fd20cd163afa849e5797619c31144a74140abe1c7540d1a3c557a9f23af6e6e3523667ffd13b92444cd3be01b1581ca0cf7a536ce4c073dc17de955ba22e469bc1c0ec213b3b7ceddfc47567a7ecfc2a58a6c2a3c2185563277866f8979bbb86af844349c6021eb9926acfe0188fd0f809e056a8e0a8aaa2a4208562e775ef60c56cadd6e26a9e52d60187bf6ed0565616020e0c2bfd79d961b1069ff261b2abf40c9ee2a2c442877f4edb8d9ad717cb434fed67ef2eacd629da1ce78023548853eeaf7d998923db7ceb0174e67875e787f398435da84c26b478ff6bf785c4714bc6f8e91804e10cc699e1be342c952d57d3c84654d603709f4f6bb596e022e2e6149c81025226b9925045ff365d83991f7d4c8693544ca7ba6da60f8e4f6723c9f14ac48882556336ed88c20163544c55ab4238e510aa910b04f445252d507af02ad24e7467920c81f2d31a71a7241be2726bb9f8b20bf2100633f616a1233801eb37597ddbe2def36ef0727515e7da178da7760a41edf9ffe98fbaa3495a35025f2bd100b3d63e940ba7d997104ac67f653d0a24a2ba2c8a355af1ee048cb116b1a492577cc7cf61226fbbbabd9cbb043839585f2e00ae673ee6becaaf5da7919921c90c74d5b8b173b8a1a650f379b3b5e5f1d04538b936fc2cd0d4f8b9df9f5052ecd9e66602815b4f96586d038d5bd5a3e44bde1ef9ff9cfcb6b9aece3129ef1f026befd299a7a8ad324149b156bc5ab868099df52a2056103432879b495b0655fc1fe8073b502f3f40d403548b1629118ce0edd41558e4215e8e241a45637a3434bf070f17dac885ed656f80783a4c47000464fe78b9db0dbb55895e271d3376bf0c50cec9a403a8729982dc5b9172b5e80a0ef03fa2a24873188f8022a6f9da8ca4f2e24aa7e29987b1060ecfe0b08e039ee1f7fb55a0cd35a73b6c25dc26e469bbc2d034265db5f74e644842bb99199f83947c97bf87532b37a8d40a06f8bc5508efb117d11dfb07325d9482cdce60aa34529546d4c8d8f98e3f5b34b5c757075fee9c3443e0a1109253f5f0a905c571e5343b277e0636a5a46ab36becf5672e93b712b9bc8e3cd3656cad1b29c16e"; +const KAT_K_HEX: &str = "11b62291b1a9d307c8240d70be0b45436db445793173f6e79fcd2b273d7f3b01"; + +#[test] +fn test_kyber768_kat_decapsulation() { + let ek = hex::decode(KAT_EK_HEX).expect("valid ek hex"); + let dk = hex::decode(KAT_DK_HEX).expect("valid dk hex"); + let ct = hex::decode(KAT_CT_HEX).expect("valid ct hex"); + let expected_k = hex::decode(KAT_K_HEX).expect("valid k hex"); + + // Exact ML-KEM-768 sizes from FIPS 203. + assert_eq!(ek.len(), EK_LEN, "ek must be 1184 bytes"); + assert_eq!(ek.len(), 1184); + assert_eq!(dk.len(), DK_LEN, "dk must be 2400 bytes"); + assert_eq!(dk.len(), 2400); + assert_eq!(ct.len(), CT_LEN, "ct must be 1088 bytes"); + assert_eq!(ct.len(), 1088); + assert_eq!(expected_k.len(), SS_LEN, "shared secret must be 32 bytes"); + assert_eq!(expected_k.len(), 32); + + // The core KAT: decapsulate the pinned ciphertext under the pinned dk. + let recovered = kyber::decapsulate(&dk, &ct).expect("decapsulation succeeds"); + assert_eq!( + recovered.as_slice(), + expected_k.as_slice(), + "decapsulated shared secret must match the NIST ACVP expected value" + ); +} + +#[test] +fn test_kyber768_sizes_on_fresh_keypair() { + let kp = kyber::generate(); + assert_eq!(kp.ek.len(), EK_LEN); + assert_eq!(kp.dk.len(), DK_LEN); + + let (ct, ss) = kyber::encapsulate(&kp.ek).expect("encapsulate"); + assert_eq!(ct.len(), CT_LEN); + assert_eq!(ss.len(), SS_LEN); +} + +#[test] +fn test_kyber768_roundtrip() { + let kp = kyber::generate(); + let (ct, ss_server) = kyber::encapsulate(&kp.ek).expect("encapsulate"); + let ss_client = kyber::decapsulate(&kp.dk, &ct).expect("decapsulate"); + assert_eq!( + ss_server, ss_client, + "fresh ML-KEM-768 roundtrip must agree" + ); +} diff --git a/crates/aura-pki/Cargo.toml b/crates/aura-pki/Cargo.toml index 87f609a..f800b81 100644 --- a/crates/aura-pki/Cargo.toml +++ b/crates/aura-pki/Cargo.toml @@ -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"] } diff --git a/crates/aura-pki/src/ca.rs b/crates/aura-pki/src/ca.rs new file mode 100644 index 0000000..2cc77d2 --- /dev/null +++ b/crates/aura-pki/src/ca.rs @@ -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 { + 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 { + 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 { + 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 { + 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::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 { + 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 { + let mut params = CertificateParams::new(Vec::::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 { + let mut params = CertificateParams::new(Vec::::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) +} diff --git a/crates/aura-pki/src/cert.rs b/crates/aura-pki/src/cert.rs new file mode 100644 index 0000000..85a879f --- /dev/null +++ b/crates/aura-pki/src/cert.rs @@ -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 { + 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) { + 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 { + 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, 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 { + 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, 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) +} diff --git a/crates/aura-pki/src/lib.rs b/crates/aura-pki/src/lib.rs index f08fa37..b8f1959 100644 --- a/crates/aura-pki/src/lib.rs +++ b/crates/aura-pki/src/lib.rs @@ -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), +} diff --git a/crates/aura-pki/src/store.rs b/crates/aura-pki/src/store.rs new file mode 100644 index 0000000..1d01f25 --- /dev/null +++ b/crates/aura-pki/src/store.rs @@ -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, +} + +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) { + 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 { + 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 { + 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 for CrlStore { + fn from_iter>(iter: T) -> Self { + Self { + revoked: iter.into_iter().collect(), + } + } +} + +impl Extend for CrlStore { + fn extend>(&mut self, iter: T) { + self.revoked.extend(iter); + } +} diff --git a/crates/aura-pki/tests/pki.rs b/crates/aura-pki/tests/pki.rs new file mode 100644 index 0000000..9e3ad16 --- /dev/null +++ b/crates/aura-pki/tests/pki.rs @@ -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::() + }; + 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()); +}