feat(crypto,pki): implement Wave 1 — hybrid KEM + PKI
aura-crypto: X25519 + ML-KEM-768 (FIPS 203) hybrid KEM, HKDF-SHA256 session key derivation, ChaCha20-Poly1305 AeadSession with counter nonces; genuine NIST ACVP ML-KEM-768 KAT (decapsulation vector). 16 tests green, clippy clean. aura-pki: self-signed CA, server/client cert issuance (rcgen 0.14), mutual X.509 chain verification via rustls-webpki, CRL revocation. 8 tests green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user