Menu
Public repository / e528555f37592778e096237d9699d1ce21694843
AkurAI-VPN / crates/akurai-transport/src/noise.rs
Latest change 1ad98859d92932cb0a7323750fee6a67f281707e - feat(transport): Noise_IK handshake + end-to-end channel test by olibuijr
//! WireGuard-style `Noise_IK` handshake, hand-rolled on the zero-dependency
//! `akurai-crypto` primitives.
//!
//! `Noise_IK` is a one-round-trip mutually-authenticated handshake. The
//! Initiator knows the Responder's static public key up front (the `K` token);
//! the Responder learns and authenticates the Initiator's static key from
//! message 1 (the `I` token). After two messages both peers hold a matched pair
//! of directional [`TransportKeys`].
//!
//! ```text
//! Initiator Responder
//! --------- msg1: e, es, s, ss (96 B) ----->
//! <-------- msg2: e, ee, se (48 B) ------
//! ```
//!
//! This implements the data-plane handshake WITHOUT the WireGuard hardening
//! layer — no pre-shared key, no MAC1/MAC2/cookie, no timestamp. Those are a
//! later phase. Every handshake AEAD uses a fresh, single-use KDF key, so a
//! constant all-zero nonce is safe.
//!
//! ## Protocol transcript
//!
//! Symmetric state is a chaining key `ck` (feeds the KDF) and a transcript hash
//! `h` (authenticates every AEAD as associated data). Both peers absorb the
//! same values in the same order, so they converge on identical `ck`/`h` and
//! therefore identical transport keys. Authentication is implicit: if any DH
//! input, static key, or ciphertext differs, a downstream AEAD tag check fails
//! and the handshake aborts.
use akurai_crypto::{blake2s, chacha20poly1305 as aead, hkdf::hkdf, x25519};
use crate::{Keypair, PublicKey, TransportKeys, CONSTRUCTION, IDENTIFIER};
/// All handshake AEAD operations use a single all-zero 96-bit nonce. Each key is
/// derived fresh by the KDF and used exactly once, so the nonce never repeats
/// under a given key.
const ZERO_NONCE: [u8; 12] = [0u8; 12];
// Wire layout sizes.
const PUB_LEN: usize = 32; // X25519 public key
const TAG_LEN: usize = 16; // ChaCha20-Poly1305 tag
const ENC_STATIC_LEN: usize = PUB_LEN + TAG_LEN; // 48: sealed static public key
const MSG1_LEN: usize = PUB_LEN + ENC_STATIC_LEN + TAG_LEN; // 96
const MSG2_LEN: usize = PUB_LEN + TAG_LEN; // 48
/// `h <- BLAKE2s(h || data)` — absorb `data` into the running transcript hash.
fn mix_hash(h: [u8; 32], data: &[u8]) -> [u8; 32] {
blake2s::hash(&[h.as_slice(), data].concat())
}
/// One-output Noise KDF: derive the next chaining key from `ck` and input `x`.
fn kdf1(ck: &[u8; 32], x: &[u8]) -> [u8; 32] {
hkdf::<1>(ck, x)[0]
}
/// Two-output Noise KDF: derive `(next chaining key, AEAD key)`.
fn kdf2(ck: &[u8; 32], x: &[u8]) -> ([u8; 32], [u8; 32]) {
let o = hkdf::<2>(ck, x);
(o[0], o[1])
}
/// The shared handshake prelude `(ck, h)`, seeded from the protocol labels and
/// the Responder's static public key. Computed identically by both peers.
fn prelude(responder_static_pub: &[u8; 32]) -> ([u8; 32], [u8; 32]) {
let ck = blake2s::hash(CONSTRUCTION);
let mut h = blake2s::hash(&[ck.as_slice(), IDENTIFIER].concat());
h = mix_hash(h, responder_static_pub);
(ck, h)
}
/// Initiator symmetric state carried from message 1 to message 2.
///
/// Opaque by design: the chaining key, transcript hash, the Initiator's own
/// static key pair, and the per-handshake ephemeral secret are all private.
pub struct Initiator {
ck: [u8; 32],
h: [u8; 32],
static_kp: Keypair,
ephemeral_secret: [u8; 32],
}
/// Build handshake message 1 (`e, es, s, ss`).
///
/// `ephemeral_secret` is supplied by the caller — deterministic for tests, fresh
/// OS entropy in production. Returns the carried [`Initiator`] state and the
/// 96-byte wire message `e_pub(32) || enc_static(48) || enc_empty(16)`.
pub fn initiate(
static_kp: &Keypair,
responder_pub: &PublicKey,
ephemeral_secret: &[u8; 32],
) -> (Initiator, Vec<u8>) {
let (mut ck, mut h) = prelude(responder_pub);
// -- e: mix the Initiator's ephemeral public key.
let e_pub = x25519::public_key(ephemeral_secret);
ck = kdf1(&ck, &e_pub);
h = mix_hash(h, &e_pub);
// -- es: DH(ephemeral, responder static) -> key encrypting the static key.
let es = x25519::shared_secret(ephemeral_secret, responder_pub);
let (new_ck, k) = kdf2(&ck, &es);
ck = new_ck;
let enc_static = aead::seal(&k, &ZERO_NONCE, &h, &static_kp.public);
h = mix_hash(h, &enc_static);
// -- ss: DH(static, responder static) -> key encrypting the empty payload.
let ss = x25519::shared_secret(&static_kp.secret, responder_pub);
let (new_ck, k) = kdf2(&ck, &ss);
ck = new_ck;
let enc_empty = aead::seal(&k, &ZERO_NONCE, &h, &[]);
h = mix_hash(h, &enc_empty);
let mut msg1 = Vec::with_capacity(MSG1_LEN);
msg1.extend_from_slice(&e_pub);
msg1.extend_from_slice(&enc_static);
msg1.extend_from_slice(&enc_empty);
let state = Initiator {
ck,
h,
static_kp: static_kp.clone(),
ephemeral_secret: *ephemeral_secret,
};
(state, msg1)
}
/// Consume message 1 and build message 2.
///
/// `static_kp` is the Responder's static key pair; `ephemeral_secret` is its
/// per-handshake ephemeral secret. On success returns the Responder's
/// [`TransportKeys`], the 48-byte message 2 `er_pub(32) || enc_empty2(16)`, and
/// the authenticated static [`PublicKey`] of the Initiator. Returns `None` on
/// any malformed input or AEAD authentication failure.
pub fn respond(
static_kp: &Keypair,
ephemeral_secret: &[u8; 32],
msg1: &[u8],
) -> Option<(TransportKeys, Vec<u8>, PublicKey)> {
if msg1.len() != MSG1_LEN {
return None;
}
let e_pub: [u8; 32] = msg1[0..PUB_LEN].try_into().ok()?;
let enc_static = &msg1[PUB_LEN..PUB_LEN + ENC_STATIC_LEN];
let enc_empty = &msg1[PUB_LEN + ENC_STATIC_LEN..MSG1_LEN];
let (mut ck, mut h) = prelude(&static_kp.public);
// -- e
ck = kdf1(&ck, &e_pub);
h = mix_hash(h, &e_pub);
// -- es: DH(responder static, initiator ephemeral) -> decrypt static key.
let es = x25519::shared_secret(&static_kp.secret, &e_pub);
let (new_ck, k) = kdf2(&ck, &es);
ck = new_ck;
let init_static_pt = aead::open(&k, &ZERO_NONCE, &h, enc_static)?;
let init_static_pub: [u8; 32] = init_static_pt.as_slice().try_into().ok()?;
h = mix_hash(h, enc_static);
// -- ss: DH(responder static, initiator static) -> decrypt empty payload.
let ss = x25519::shared_secret(&static_kp.secret, &init_static_pub);
let (new_ck, k) = kdf2(&ck, &ss);
ck = new_ck;
let empty = aead::open(&k, &ZERO_NONCE, &h, enc_empty)?;
if !empty.is_empty() {
return None;
}
h = mix_hash(h, enc_empty);
// ---- message 2 ----
let er_pub = x25519::public_key(ephemeral_secret);
ck = kdf1(&ck, &er_pub);
h = mix_hash(h, &er_pub);
// -- ee: DH(responder ephemeral, initiator ephemeral).
let ee = x25519::shared_secret(ephemeral_secret, &e_pub);
ck = kdf1(&ck, &ee);
// -- se: DH(responder ephemeral, initiator static).
let se = x25519::shared_secret(ephemeral_secret, &init_static_pub);
ck = kdf1(&ck, &se);
let (new_ck, k) = kdf2(&ck, &[]);
ck = new_ck;
let enc_empty2 = aead::seal(&k, &ZERO_NONCE, &h, &[]);
// Final transcript absorption — completes the handshake hash; the value is
// not needed for the key split, so it is intentionally not retained.
let _ = mix_hash(h, &enc_empty2);
let mut msg2 = Vec::with_capacity(MSG2_LEN);
msg2.extend_from_slice(&er_pub);
msg2.extend_from_slice(&enc_empty2);
// Split: Responder is the second party, so its send key is the second output.
let (t1, t2) = kdf2(&ck, &[]);
let keys = TransportKeys { send: t2, recv: t1 };
Some((keys, msg2, init_static_pub))
}
/// Consume message 2 and finish the handshake.
///
/// Returns the Initiator's [`TransportKeys`], or `None` if message 2 is
/// malformed or fails AEAD authentication.
pub fn finalize(state: Initiator, msg2: &[u8]) -> Option<TransportKeys> {
if msg2.len() != MSG2_LEN {
return None;
}
let er_pub: [u8; 32] = msg2[0..PUB_LEN].try_into().ok()?;
let enc_empty2 = &msg2[PUB_LEN..MSG2_LEN];
let mut ck = state.ck;
let mut h = state.h;
// -- e
ck = kdf1(&ck, &er_pub);
h = mix_hash(h, &er_pub);
// -- ee: DH(initiator ephemeral, responder ephemeral).
let ee = x25519::shared_secret(&state.ephemeral_secret, &er_pub);
ck = kdf1(&ck, &ee);
// -- se: DH(initiator static, responder ephemeral).
let se = x25519::shared_secret(&state.static_kp.secret, &er_pub);
ck = kdf1(&ck, &se);
let (new_ck, k) = kdf2(&ck, &[]);
ck = new_ck;
let empty = aead::open(&k, &ZERO_NONCE, &h, enc_empty2)?;
if !empty.is_empty() {
return None;
}
// Final transcript absorption (see `respond`); value intentionally unused.
let _ = mix_hash(h, enc_empty2);
// Split: Initiator is the first party, so its send key is the first output.
let (t1, t2) = kdf2(&ck, &[]);
Some(TransportKeys { send: t1, recv: t2 })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keypair;
// Fixed, deterministic test material (any 32-byte arrays).
fn fixtures() -> (Keypair, Keypair, [u8; 32], [u8; 32]) {
let init_static = Keypair::from_secret([1u8; 32]);
let resp_static = Keypair::from_secret([2u8; 32]);
let init_eph = [3u8; 32];
let resp_eph = [4u8; 32];
(init_static, resp_static, init_eph, resp_eph)
}
/// Full handshake: keys mirror across peers and the Initiator's static key
/// is recovered by the Responder.
#[test]
fn noise_round_trip() {
let (init_static, resp_static, init_eph, resp_eph) = fixtures();
let (state, msg1) = initiate(&init_static, &resp_static.public, &init_eph);
let (resp_keys, msg2, recovered_init_pub) =
respond(&resp_static, &resp_eph, &msg1).expect("responder must accept msg1");
let init_keys = finalize(state, &msg2).expect("initiator must accept msg2");
// Directional keys are mirror images across the two peers.
assert_eq!(
init_keys.send, resp_keys.recv,
"init.send must equal resp.recv"
);
assert_eq!(
init_keys.recv, resp_keys.send,
"init.recv must equal resp.send"
);
// The Responder authenticated and recovered the Initiator's static key.
assert_eq!(
recovered_init_pub,
Keypair::from_secret([1u8; 32]).public,
"responder must recover the initiator's static public key",
);
// Sanity: keys are non-degenerate and the two directions differ.
assert_ne!(init_keys.send, [0u8; 32]);
assert_ne!(init_keys.recv, [0u8; 32]);
assert_ne!(init_keys.send, init_keys.recv);
}
/// A single flipped byte in message 1 fails AEAD authentication.
#[test]
fn noise_msg1_tamper_rejected() {
let (init_static, resp_static, init_eph, resp_eph) = fixtures();
let (_state, mut msg1) = initiate(&init_static, &resp_static.public, &init_eph);
msg1[40] ^= 0x01; // inside the sealed static key
assert!(
respond(&resp_static, &resp_eph, &msg1).is_none(),
"tampered msg1 must be rejected",
);
}
/// A single flipped byte in message 2 fails AEAD authentication.
#[test]
fn noise_msg2_tamper_rejected() {
let (init_static, resp_static, init_eph, resp_eph) = fixtures();
let (state, msg1) = initiate(&init_static, &resp_static.public, &init_eph);
let (_keys, mut msg2, _pub) =
respond(&resp_static, &resp_eph, &msg1).expect("responder must accept msg1");
msg2[40] ^= 0x01; // inside enc_empty2's authentication tag
assert!(
finalize(state, &msg2).is_none(),
"tampered msg2 must be rejected",
);
}
/// Wire messages have the exact specified lengths.
#[test]
fn noise_message_sizes() {
let (init_static, resp_static, init_eph, resp_eph) = fixtures();
let (state, msg1) = initiate(&init_static, &resp_static.public, &init_eph);
assert_eq!(msg1.len(), 96, "msg1 must be 96 bytes");
let (_keys, msg2, _pub) =
respond(&resp_static, &resp_eph, &msg1).expect("responder must accept msg1");
assert_eq!(msg2.len(), 48, "msg2 must be 48 bytes");
finalize(state, &msg2).expect("initiator must accept msg2");
}
}