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:
Generated
+4
@@ -231,7 +231,9 @@ dependencies = [
|
|||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"rustls-webpki",
|
||||||
"thiserror 1.0.69",
|
"thiserror 1.0.69",
|
||||||
|
"time",
|
||||||
"uuid",
|
"uuid",
|
||||||
"x509-parser 0.16.0",
|
"x509-parser 0.16.0",
|
||||||
]
|
]
|
||||||
@@ -719,6 +721,7 @@ version = "0.2.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.4.2",
|
||||||
"hybrid-array",
|
"hybrid-array",
|
||||||
"rand_core 0.10.1",
|
"rand_core 0.10.1",
|
||||||
]
|
]
|
||||||
@@ -1099,6 +1102,7 @@ dependencies = [
|
|||||||
"cfg-if",
|
"cfg-if",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 6.0.0",
|
"r-efi 6.0.0",
|
||||||
|
"rand_core 0.10.1",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,8 +6,14 @@ license.workspace = true
|
|||||||
description = "Aura cryptographic core: hybrid X25519 + ML-KEM-768 KEM, HKDF, ChaCha20-Poly1305"
|
description = "Aura cryptographic core: hybrid X25519 + ML-KEM-768 KEM, HKDF, ChaCha20-Poly1305"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ml-kem.workspace = true
|
# `getrandom`: enables ML-KEM's no-argument `generate_keypair()` / `encapsulate()` which use the
|
||||||
x25519-dalek.workspace = true
|
# 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
|
hkdf.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
@@ -21,3 +27,7 @@ thiserror.workspace = true
|
|||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
hex.workspace = true
|
hex.workspace = true
|
||||||
criterion.workspace = true
|
criterion.workspace = true
|
||||||
|
|
||||||
|
[[bench]]
|
||||||
|
name = "crypto"
|
||||||
|
harness = false
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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<u8> {
|
||||||
|
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<Vec<u8>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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::<Sha256>::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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<HybridSharedSecret, CryptoError> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>` / `[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<MlKem768>;
|
||||||
|
/// Concrete ML-KEM-768 decapsulation (secret) key type.
|
||||||
|
type Dk = <MlKem768 as Kem>::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<u8>,
|
||||||
|
/// Encapsulation (public) key, 1184 bytes.
|
||||||
|
pub ek: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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>, [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<Ek, CryptoError> {
|
||||||
|
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<Dk, CryptoError> {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Vec<u8>> = 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);
|
||||||
|
}
|
||||||
@@ -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: <https://github.com/usnistgov/ACVP-Server>,
|
||||||
|
//! `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"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,10 +6,17 @@ license.workspace = true
|
|||||||
description = "Aura PKI: CA, X.509 issuance and mutual-auth verification"
|
description = "Aura PKI: CA, X.509 issuance and mutual-auth verification"
|
||||||
|
|
||||||
[dependencies]
|
[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.workspace = true
|
||||||
rustls-pki-types.workspace = true
|
rustls-pki-types.workspace = true
|
||||||
x509-parser.workspace = true
|
x509-parser.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
anyhow.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