initial commit
Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled

Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
This commit is contained in:
Kamal Tufekcic 2026-04-02 23:48:10 +03:00
commit 1d99048c95
No known key found for this signature in database
165830 changed files with 79062 additions and 0 deletions

254
soliton/src/auth/mod.rs Normal file
View file

@ -0,0 +1,254 @@
//! KEM-based authentication (§4).
//!
//! Proves possession of a LO identity private key via X-Wing encapsulation.
//! Server encapsulates, client decapsulates and proves knowledge of the shared secret.
use crate::constants;
use crate::error::Result;
use crate::identity::{self, IdentityPublicKey, IdentitySecretKey};
use crate::primitives::{hmac, xwing};
use zeroize::{Zeroize, Zeroizing};
/// Server-side: generate an authentication challenge.
///
/// Encapsulates to the client's identity key (X-Wing component) and computes
/// the expected proof token. Returns (ciphertext, expected_token).
///
/// The server sends `ciphertext` to the client and retains `expected_token`
/// for verification. The token is wrapped in `Zeroizing` to ensure it is
/// zeroized from memory when no longer needed.
///
/// # Security
///
/// The X-Wing shared secret is zeroized immediately after HMAC derivation.
/// The returned token is wrapped in `Zeroizing` and zeroized on drop.
///
/// # Caller Obligations
///
/// The proof token is computed as `HMAC(ss, "lo-auth-v1")` with a static label.
/// No server identity, session ID, or timestamp is bound into the HMAC. The
/// caller must ensure freshness and context binding externally:
/// - Use the ciphertext only once (single-use challenge).
/// - Bind the challenge to a specific session/connection at the application layer.
/// - Enforce a timeout on proof delivery to prevent delayed replay.
///
/// Without these measures, a valid proof is replayable across any server that
/// issues the same ciphertext (which requires the same public key).
#[must_use = "contains secret token material that must not be silently discarded"]
pub fn auth_challenge(
client_pk: &IdentityPublicKey,
) -> Result<(xwing::Ciphertext, Zeroizing<[u8; 32]>)> {
let (ct, mut ss) = identity::encapsulate(client_pk)?;
// token = HMAC-SHA3-256(ss, "lo-auth-v1")
let mut raw_token = hmac::hmac_sha3_256(ss.as_bytes(), constants::AUTH_HMAC_LABEL);
let token = Zeroizing::new(raw_token);
// [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
raw_token.zeroize();
// Shared secret used for token derivation — zeroize eagerly to minimize the
// window during which it resides in memory; ZeroizeOnDrop fires again at drop.
ss.0.zeroize();
Ok((ct, token))
}
/// Client-side: respond to an authentication challenge.
///
/// Decapsulates the ciphertext using the identity secret key and computes
/// the proof. Returns the 32-byte proof to send back to the server, wrapped
/// in `Zeroizing` to ensure it is zeroized from memory after use.
///
/// # Security
///
/// The X-Wing shared secret is zeroized immediately after HMAC derivation.
/// The returned proof is wrapped in `Zeroizing` and zeroized on drop.
#[must_use = "contains secret proof material that must not be silently discarded"]
pub fn auth_respond(
client_sk: &IdentitySecretKey,
ct: &xwing::Ciphertext,
) -> Result<Zeroizing<[u8; 32]>> {
let mut ss = identity::decapsulate(client_sk, ct)?;
// proof = HMAC-SHA3-256(ss, "lo-auth-v1")
let mut raw_proof = hmac::hmac_sha3_256(ss.as_bytes(), constants::AUTH_HMAC_LABEL);
let proof = Zeroizing::new(raw_proof);
// [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
raw_proof.zeroize();
// Shared secret used for proof derivation — zeroize eagerly to minimize the
// window during which it resides in memory; ZeroizeOnDrop fires again at drop.
ss.0.zeroize();
Ok(proof)
}
/// Server-side: verify a client's authentication proof.
///
/// Constant-time comparison of the proof against the expected token.
///
/// # Security
///
/// Uses `subtle::ConstantTimeEq` via `hmac_sha3_256_verify_raw` — execution time
/// does not depend on which bytes differ. The caller must zeroize
/// `expected_token` after verification (§4.4); `Zeroizing<[u8; 32]>` from
/// [`auth_challenge`] satisfies this automatically.
#[must_use]
pub fn auth_verify(expected_token: &[u8; 32], proof: &[u8; 32]) -> bool {
hmac::hmac_sha3_256_verify_raw(expected_token, proof)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::{GeneratedIdentity, generate_identity};
#[test]
fn challenge_response_verify() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (ct, token) = auth_challenge(&pk).unwrap();
let proof = auth_respond(&sk, &ct).unwrap();
assert!(auth_verify(&token, &proof));
}
#[test]
fn verify_wrong_proof() {
// Use a real challenge/response pair and flip one byte in the proof.
// This exercises constant-time comparison on near-equal inputs rather
// than fully independent random arrays.
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (ct, token) = auth_challenge(&pk).unwrap();
let mut proof = auth_respond(&sk, &ct).unwrap();
proof[0] ^= 0x01;
assert!(!auth_verify(&token, &proof));
}
#[test]
fn verify_different_client() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
secret_key: sk_b, ..
} = generate_identity().unwrap();
let (ct, token) = auth_challenge(&pk_a).unwrap();
// B responds to A's challenge — decapsulation produces a different SS.
let proof = auth_respond(&sk_b, &ct).unwrap();
assert!(!auth_verify(&token, &proof));
}
#[test]
fn token_is_nonzero() {
let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
let (_, token) = auth_challenge(&pk).unwrap();
// Length enforced by type (`Zeroizing<[u8; 32]>`); this guards against all-zero HMAC output.
assert!(token.iter().any(|&b| b != 0));
}
#[test]
fn proof_is_nonzero() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (ct, _) = auth_challenge(&pk).unwrap();
let proof = auth_respond(&sk, &ct).unwrap();
// Length enforced by type (`Zeroizing<[u8; 32]>`); this guards against all-zero HMAC output.
assert!(proof.iter().any(|&b| b != 0));
}
#[test]
fn auth_respond_wrong_ciphertext_fails_verify() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (_, token) = auth_challenge(&pk).unwrap();
// Create a second challenge to get a different ciphertext.
let (ct2, _) = auth_challenge(&pk).unwrap();
let proof = auth_respond(&sk, &ct2).unwrap();
// Different ciphertext → different SS → different proof.
assert!(!auth_verify(&token, &proof));
}
#[test]
fn auth_token_is_hmac_of_shared_secret() {
// Verify indirectly: challenge + respond for same identity produces
// matching token and proof, confirming both derive from the same
// HMAC(shared_secret, AUTH_HMAC_LABEL) computation.
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (ct, token) = auth_challenge(&pk).unwrap();
let proof = auth_respond(&sk, &ct).unwrap();
// Token and proof should be byte-identical (both = HMAC(ss, label)).
assert_eq!(*token, *proof);
}
// === Unit-level tests for auth_verify with raw known inputs ===
// These do not require PQ keygen; they exercise auth_verify's
// constant-time comparison directly and are MIRI-safe.
#[test]
fn auth_verify_matching_raw_tokens_returns_true() {
let token = [0x42u8; 32];
assert!(auth_verify(&token, &token));
}
#[test]
fn auth_verify_mismatched_raw_tokens_returns_false() {
let token = [0x42u8; 32];
let mut wrong = token;
wrong[0] ^= 0x01;
assert!(!auth_verify(&token, &wrong));
}
#[test]
fn auth_verify_all_zero_tokens_returns_true() {
// Exercises the all-zero edge case (degenerate but defined behavior).
let token = [0u8; 32];
assert!(auth_verify(&token, &token));
}
#[test]
fn auth_verify_single_bit_flip_returns_false() {
// Any single-bit difference must be detected.
for bit in 0..256u32 {
let byte = (bit / 8) as usize;
let mask = 1u8 << (bit % 8);
let token = [0xABu8; 32];
let mut wrong = token;
wrong[byte] ^= mask;
assert!(!auth_verify(&token, &wrong), "bit {} not detected", bit);
}
}
#[test]
fn auth_challenges_produce_unique_ciphertexts() {
// Two auth_challenge calls for the same key must produce different
// ciphertexts — encapsulation is randomised, so replaying a captured
// ciphertext from a previous challenge provides no proof of key possession.
let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
let (ct1, _) = auth_challenge(&pk).unwrap();
let (ct2, _) = auth_challenge(&pk).unwrap();
assert_ne!(
ct1.as_bytes(),
ct2.as_bytes(),
"consecutive auth_challenge calls must produce distinct ciphertexts"
);
}
}

691
soliton/src/call.rs Normal file
View file

@ -0,0 +1,691 @@
//! E2EE voice call key derivation.
//!
//! Derives call encryption key material for encrypted voice calls from the ratchet root
//! key and an ephemeral X-Wing KEM shared secret exchanged during call
//! signaling. The ephemeral KEM provides forward secrecy independent of
//! the ratchet — if the root key is later compromised (before the next KEM
//! ratchet step), the call content remains confidential.
//!
//! ## Key Derivation
//!
//! ```text
//! HKDF(salt=rk, ikm=kem_ss ‖ call_id, info="lo-call-v1" ‖ fp_lo ‖ fp_hi, L=96)
//! → key_a (32) | key_b (32) | chain_key (32)
//! ```
//!
//! ## Role Assignment
//!
//! The party with the lexicographically lower identity fingerprint uses
//! `key_a` as their send key and `key_b` as their recv key; the other
//! party reverses the assignment.
//!
//! ## Intra-Call Rekeying
//!
//! `CallKeys::advance()` ratchets the internal chain key forward:
//!
//! ```text
//! key_a' = HMAC-SHA3-256(chain_key, 0x04)
//! key_b' = HMAC-SHA3-256(chain_key, 0x05)
//! chain_key' = HMAC-SHA3-256(chain_key, 0x06)
//! ```
//!
//! The old chain key and call encryption keys are zeroized. This provides forward
//! secrecy within the call — compromise of a later call encryption key does not
//! reveal earlier media segments.
use crate::constants;
use crate::error::{Error, Result};
use crate::primitives::{hkdf, hmac};
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// Maximum number of `advance()` calls before the chain is exhausted.
/// 2^24 (16,777,216) steps — far beyond any realistic call duration even with
/// aggressive per-second rekeying.
const MAX_CALL_ADVANCE: u32 = 1 << 24;
/// Call encryption key state for an active E2EE voice/video call.
///
/// Holds the current send key, receive key, and chain key for intra-call
/// rekeying (§6.12). Role assignment (which key is send vs recv) is
/// determined by fingerprint ordering at construction time.
///
/// ## Lifecycle
///
/// 1. Obtain via `derive_call_keys` after the ephemeral KEM exchange.
/// 2. Read `send_key()` and `recv_key()` to configure the call encryption session.
/// 3. Periodically call `advance()` to rekey — `send_key()` and `recv_key()`
/// then return the new keys.
///
/// # Thread Safety
///
/// `CallKeys` auto-derives `Send + Sync` but is not designed for concurrent
/// access. `advance` requires `&mut self`. The CAPI layer adds a runtime
/// reentrancy guard for FFI callers.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct CallKeys {
/// Current send key (32 bytes).
send_key: [u8; 32],
/// Current recv key (32 bytes).
recv_key: [u8; 32],
/// Chain key for intra-call rekeying.
chain_key: [u8; 32],
/// `true` if the local party has the lexicographically lower fingerprint,
/// determining which HMAC output becomes the send vs recv key on each
/// `advance()` call.
#[zeroize(skip)]
lower_role: bool,
/// Number of `advance()` calls so far. Monotonically increasing;
/// `advance()` returns `ChainExhausted` at `MAX_CALL_ADVANCE`.
#[zeroize(skip)]
step_count: u32,
}
impl CallKeys {
/// Return the current send key (32 bytes).
pub fn send_key(&self) -> &[u8; 32] {
&self.send_key
}
/// Return the current recv key (32 bytes).
pub fn recv_key(&self) -> &[u8; 32] {
&self.recv_key
}
/// Return the current chain key bytes (test-utils only).
#[cfg(all(feature = "test-utils", debug_assertions))]
#[deprecated(note = "test-utils only — do not call in production code")]
pub fn chain_key_bytes(&self) -> &[u8; 32] {
&self.chain_key
}
/// Advance the call chain, replacing the current call encryption keys with fresh
/// key material.
///
/// Derives two new call encryption keys and a new chain key from the current chain
/// key via HMAC-SHA3-256. The old chain key and call encryption keys are zeroized
/// before replacement.
///
/// After this call, [`send_key`](Self::send_key) and
/// [`recv_key`](Self::recv_key) return the new keys. Role assignment
/// (which HMAC output is send vs recv) is preserved from the original
/// [`derive_call_keys`] call.
///
/// # Errors
///
/// Returns `ChainExhausted` after `MAX_CALL_ADVANCE` (2^24) calls. The
/// caller must establish a new call with a fresh KEM exchange rather than
/// continuing to rekey the exhausted chain.
pub fn advance(&mut self) -> Result<()> {
if self.step_count >= MAX_CALL_ADVANCE {
// Zeroize all key material on exhaustion — prevents callers that
// ignore the error from continuing with stale keys.
self.send_key.zeroize();
self.recv_key.zeroize();
self.chain_key.zeroize();
return Err(Error::ChainExhausted);
}
let mut key_a = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_KEY_A_BYTE);
let mut key_b = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_KEY_B_BYTE);
let mut next_chain = hmac::hmac_sha3_256(&self.chain_key, constants::CALL_CHAIN_ADV_BYTE);
// [u8; 32] is Copy — field assignment copies new values without
// dropping the old ones, so explicit zeroization prevents the
// previous keys from lingering on the stack.
self.chain_key.zeroize();
self.send_key.zeroize();
self.recv_key.zeroize();
self.chain_key = next_chain;
if self.lower_role {
self.send_key = key_a;
self.recv_key = key_b;
} else {
self.send_key = key_b;
self.recv_key = key_a;
}
// [u8; 32] is Copy — all assignments above created bitwise copies.
// Zeroize the stack originals.
key_a.zeroize();
key_b.zeroize();
next_chain.zeroize();
// Cannot overflow: MAX_CALL_ADVANCE (2^24) << u32::MAX, and the
// >= MAX_CALL_ADVANCE guard above bounds step_count before this point.
self.step_count += 1;
Ok(())
}
}
/// Derive call keys from the ratchet root key, an ephemeral KEM shared
/// secret, and a random call identifier.
///
/// Both parties must call this function with identical `root_key`, `kem_ss`,
/// and `call_id` values. The `local_fp`/`remote_fp` parameters determine
/// role assignment (which half becomes the send vs recv key) and are
/// naturally swapped between the two parties.
///
/// ## Protocol
///
/// 1. Call initiator (Alice) generates `call_id` (16 random bytes) and an
/// ephemeral X-Wing keypair `(ek_pub, ek_sk)`. Sends `call_id` + `ek_pub`
/// in a `CallOffer` message (encrypted via the ratchet).
/// 2. Responder (Bob) encapsulates to Alice's ephemeral public key:
/// `(ct, kem_ss) = XWing.Encaps(ek_pub)`. Sends `CallAnswer { ct }`
/// (encrypted via the ratchet).
/// 3. Alice decapsulates: `kem_ss = XWing.Decaps(ek_sk, ct)`. Zeroizes
/// `ek_sk` immediately.
/// 4. Both parties call this function with the same inputs to derive matching
/// call encryption keys.
///
/// ## Role Assignment
///
/// The party with the lexicographically lower identity fingerprint uses
/// `key_a` (first HKDF output half) as their send key and `key_b` (second
/// half) as their recv key. The other party reverses the assignment. Both
/// parties arrive at the correct assignment because `local_fp`/`remote_fp`
/// are swapped between them.
///
/// # Security
///
/// The ephemeral KEM exchange binds call security to a fresh shared secret
/// independent of the ratchet state. Even if `root_key` is later compromised
/// (before the next ratchet KEM step), the call remains confidential — the
/// ephemeral KEM secret is zeroized after key derivation.
///
/// `root_key` in the HKDF salt provides defense-in-depth: if the ephemeral
/// KEM is broken (e.g., by a future quantum computer), the root key's
/// accumulated post-quantum security still protects the call.
///
/// ## `call_id` Uniqueness
///
/// `call_id` (16 random bytes, generated by the call initiator) is
/// concatenated with `kem_ss` to form the HKDF IKM. Because a fresh
/// ephemeral KEM exchange already guarantees a unique `kem_ss` per call,
/// `call_id` is not the primary domain-separation mechanism — it provides
/// defense-in-depth against KEM randomness failure. The caller (application
/// layer) is responsible for generating `call_id` via a CSPRNG and must
/// never reuse a `call_id` for a different call under the same root key.
/// `call_id` is not bound into the HKDF info label because it is already
/// mixed into the IKM; binding it to both would be redundant.
///
/// # Errors
///
/// Returns `Err(InvalidData)` if:
/// - `root_key` is all-zeros (indicates uninitialized ratchet state)
/// - `kem_ss` is all-zeros (indicates KEM failure or uninitialized buffer)
/// - `call_id` is all-zeros (indicates uninitialized buffer)
/// - `local_fp == remote_fp` (equal fingerprints collapse send/recv role separation)
pub fn derive_call_keys(
root_key: &[u8; 32],
kem_ss: &[u8; 32],
call_id: &[u8; 16],
local_fp: &[u8; 32],
remote_fp: &[u8; 32],
) -> Result<CallKeys> {
// All-zeros root_key indicates an uninitialized or reset ratchet.
// Call keys derived from a zero root key provide no binding to the
// ratchet session — an attacker who knows kem_ss could derive them.
// Constant-time: root_key is secret material.
if bool::from(root_key.ct_eq(&[0u8; 32])) {
return Err(Error::InvalidData);
}
// All-zeros kem_ss indicates a KEM failure or uninitialized buffer.
// Ephemeral forward secrecy would be entirely lost — the call keys would
// depend only on root_key and call_id, both potentially attacker-known.
// Constant-time: kem_ss is secret material (X-Wing shared secret).
if bool::from(kem_ss.ct_eq(&[0u8; 32])) {
return Err(Error::InvalidData);
}
// All-zeros call_id indicates an uninitialized buffer from the caller.
// call_id is mixed into the IKM for domain separation — a zero value
// still produces unique keys (from kem_ss), but indicates a programming
// error rather than a legitimate random identifier.
if call_id == &[0u8; 16] {
return Err(Error::InvalidData);
}
// Equal fingerprints give both parties the same role (lower_role = false
// for both since `<` is strict), silently collapsing send/recv separation.
if local_fp == remote_fp {
return Err(Error::InvalidData);
}
// Concatenate kem_ss ‖ call_id as HKDF input keying material.
// Both components are fixed-size (&[u8; 32] and &[u8; 16]), so the
// concatenation is unambiguous — no length-prefix needed.
let mut ikm = [0u8; 48];
ikm[..32].copy_from_slice(kem_ss);
ikm[32..].copy_from_slice(call_id);
// HKDF info: label ‖ lower_fp ‖ higher_fp. Fingerprints are sorted into
// canonical (lower-first) order so both parties produce identical info
// regardless of which is local vs remote. This binds session identity
// into the key derivation — defense-in-depth against KEX-level collisions
// producing identical root_key for different identity pairs.
let (fp_lo, fp_hi) = if local_fp < remote_fp {
(local_fp, remote_fp)
} else {
(remote_fp, local_fp)
};
let mut info = [0u8; 10 + 32 + 32]; // CALL_HKDF_INFO (10) + 2 × fingerprint (32)
info[..10].copy_from_slice(constants::CALL_HKDF_INFO);
info[10..42].copy_from_slice(fp_lo);
info[42..74].copy_from_slice(fp_hi);
// HKDF output: 96 bytes → key_a (32) | key_b (32) | chain_key (32).
let mut output = [0u8; 96];
// Output size (96) is compile-time constant — InvalidLength structurally unreachable.
hkdf::hkdf_sha3_256(root_key, &ikm, &info, &mut output)?;
// IKM contains kem_ss (secret) — zeroize immediately after HKDF.
ikm.zeroize();
// Role assignment: lexicographically lower fingerprint gets key_a as
// send key. Both parties compute the same assignment because they swap
// local_fp and remote_fp. Variable-time comparison is acceptable —
// fingerprints are public identity material, not secret.
let lower_role = local_fp < remote_fp;
let (send_off, recv_off) = if lower_role { (0, 32) } else { (32, 0) };
let mut send_key = [0u8; 32];
let mut recv_key = [0u8; 32];
let mut chain_key = [0u8; 32];
send_key.copy_from_slice(&output[send_off..send_off + 32]);
recv_key.copy_from_slice(&output[recv_off..recv_off + 32]);
chain_key.copy_from_slice(&output[64..96]);
// HKDF output contains all derived key material — zeroize before
// leaving scope.
output.zeroize();
let keys = CallKeys {
send_key,
recv_key,
chain_key,
lower_role,
step_count: 0,
};
// [u8; 32] is Copy — struct initialization created bitwise copies;
// zeroize the stack originals before returning.
send_key.zeroize();
recv_key.zeroize();
chain_key.zeroize();
Ok(keys)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::primitives::{hkdf, hmac};
use std::collections::HashSet;
// Fixed test inputs.
const RK: [u8; 32] = [0x01u8; 32];
const SS: [u8; 32] = [0x02u8; 32];
const CALL_ID: [u8; 16] = [0x03u8; 16];
const FP_LO: [u8; 32] = [0x00u8; 32]; // lexicographically lower
const FP_HI: [u8; 32] = [0xFFu8; 32]; // lexicographically higher
/// Independently compute the HKDF output for known inputs.
///
/// Deliberately uses literal `b"lo-call-v1"` instead of `constants::CALL_HKDF_INFO`
/// so that a constant change is caught by `derive_hkdf_kat`.
fn reference_hkdf(rk: &[u8; 32], ss: &[u8; 32], call_id: &[u8; 16]) -> [u8; 96] {
let mut ikm = [0u8; 48];
ikm[..32].copy_from_slice(ss);
ikm[32..].copy_from_slice(call_id);
// Info: label ‖ lower_fp ‖ higher_fp (canonical order).
let mut info = [0u8; 10 + 32 + 32];
info[..10].copy_from_slice(b"lo-call-v1");
info[10..42].copy_from_slice(&FP_LO); // FP_LO < FP_HI
info[42..74].copy_from_slice(&FP_HI);
let mut output = [0u8; 96];
hkdf::hkdf_sha3_256(rk, &ikm, &info, &mut output).unwrap();
output
}
#[test]
fn derive_round_trip_matching() {
let alice = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
let bob = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
assert_eq!(alice.send_key(), bob.recv_key());
assert_eq!(alice.recv_key(), bob.send_key());
}
#[test]
fn derive_hkdf_kat() {
let output = reference_hkdf(&RK, &SS, &CALL_ID);
let key_a = &output[..32];
let key_b = &output[32..64];
// FP_LO < FP_HI → lower_role=true → send=key_a, recv=key_b
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert_eq!(keys.send_key().as_slice(), key_a);
assert_eq!(keys.recv_key().as_slice(), key_b);
}
#[test]
fn advance_hmac_kat() {
let output = reference_hkdf(&RK, &SS, &CALL_ID);
let chain_key = &output[64..96];
// First advance: chain_key → HMAC(ck, 0x04) / HMAC(ck, 0x05).
let expected_a = hmac::hmac_sha3_256(chain_key, constants::CALL_KEY_A_BYTE);
let expected_b = hmac::hmac_sha3_256(chain_key, constants::CALL_KEY_B_BYTE);
// Derive expected next chain key via advance byte, then verify the second advance.
let expected_chain2 = hmac::hmac_sha3_256(chain_key, constants::CALL_CHAIN_ADV_BYTE);
let expected_a2 = hmac::hmac_sha3_256(&expected_chain2, constants::CALL_KEY_A_BYTE);
let expected_b2 = hmac::hmac_sha3_256(&expected_chain2, constants::CALL_KEY_B_BYTE);
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
keys.advance().unwrap();
// lower_role=true → send=key_a, recv=key_b
assert_eq!(*keys.send_key(), expected_a);
assert_eq!(*keys.recv_key(), expected_b);
// Second advance confirms CALL_CHAIN_ADV_BYTE drives the chain: the new
// keys must match HMAC(expected_chain2, CALL_KEY_A/B_BYTE).
keys.advance().unwrap();
assert_eq!(*keys.send_key(), expected_a2);
assert_eq!(*keys.recv_key(), expected_b2);
}
#[test]
fn derive_different_call_ids() {
let k1 = derive_call_keys(&RK, &SS, &[0x01u8; 16], &FP_LO, &FP_HI).unwrap();
let k2 = derive_call_keys(&RK, &SS, &[0x02u8; 16], &FP_LO, &FP_HI).unwrap();
assert_ne!(k1.send_key(), k2.send_key());
}
#[test]
fn derive_different_kem_ss() {
let k1 = derive_call_keys(&RK, &[0xAAu8; 32], &CALL_ID, &FP_LO, &FP_HI).unwrap();
let k2 = derive_call_keys(&RK, &[0xBBu8; 32], &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert_ne!(k1.send_key(), k2.send_key());
}
#[test]
fn derive_different_root_keys() {
let k1 = derive_call_keys(&[0xAAu8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
let k2 = derive_call_keys(&[0xBBu8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert_ne!(k1.send_key(), k2.send_key());
}
#[test]
fn role_assignment_lower_fp_is_send() {
let output = reference_hkdf(&RK, &SS, &CALL_ID);
let key_a = &output[..32];
let key_b = &output[32..64];
// Lower fp → send=key_a
let lower = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert_eq!(lower.send_key().as_slice(), key_a);
assert_eq!(lower.recv_key().as_slice(), key_b);
// Higher fp → send=key_b
let higher = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
assert_eq!(higher.send_key().as_slice(), key_b);
assert_eq!(higher.recv_key().as_slice(), key_a);
}
#[test]
fn advance_changes_keys() {
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
let send_before = *keys.send_key();
let recv_before = *keys.recv_key();
keys.advance().unwrap();
assert_ne!(*keys.send_key(), send_before);
assert_ne!(*keys.recv_key(), recv_before);
}
#[test]
fn advance_preserves_role() {
let mut alice = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
let mut bob = derive_call_keys(&RK, &SS, &CALL_ID, &FP_HI, &FP_LO).unwrap();
alice.advance().unwrap();
bob.advance().unwrap();
assert_eq!(alice.send_key(), bob.recv_key());
assert_eq!(alice.recv_key(), bob.send_key());
}
#[test]
fn advance_produces_distinct_keys() {
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
keys.advance().unwrap();
let after_first = *keys.send_key();
keys.advance().unwrap();
let after_second = *keys.send_key();
assert_ne!(after_first, after_second);
}
#[test]
fn advance_keys_independent_of_chain_key() {
// Intra-call forward secrecy invariant: knowing the exposed message keys
// (send_key, recv_key) must not reveal the chain key that generates all
// future keys. Verified by asserting send_key != chain_key and
// recv_key != chain_key after each advance step.
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
for _ in 0..10 {
keys.advance().unwrap();
#[allow(deprecated)]
let ck = *keys.chain_key_bytes();
assert_ne!(*keys.send_key(), ck, "send_key == chain_key after advance");
assert_ne!(*keys.recv_key(), ck, "recv_key == chain_key after advance");
}
}
#[test]
fn advance_multiple_steps() {
// Inserts send_key before each advance: collects the initial key plus
// keys after advances 1-99 (100 values across 99 steps), all unique.
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
let mut seen = HashSet::new();
for _ in 0..100 {
seen.insert(*keys.send_key());
keys.advance().unwrap();
}
assert_eq!(seen.len(), 100);
}
#[test]
fn send_recv_keys_differ() {
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert_ne!(keys.send_key(), keys.recv_key());
}
#[test]
fn keys_are_32_bytes() {
let keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
assert!(keys.send_key().iter().any(|&b| b != 0));
assert!(keys.recv_key().iter().any(|&b| b != 0));
}
#[test]
fn zero_kem_ss_rejected() {
assert!(matches!(
derive_call_keys(&RK, &[0u8; 32], &CALL_ID, &FP_LO, &FP_HI),
Err(crate::error::Error::InvalidData)
));
}
#[test]
fn zero_root_key_rejected() {
assert!(matches!(
derive_call_keys(&[0u8; 32], &SS, &CALL_ID, &FP_LO, &FP_HI),
Err(crate::error::Error::InvalidData)
));
}
#[test]
fn zero_call_id_rejected() {
assert!(matches!(
derive_call_keys(&RK, &SS, &[0u8; 16], &FP_LO, &FP_HI),
Err(crate::error::Error::InvalidData)
));
}
#[test]
fn advance_exhaustion_returns_chain_exhausted() {
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
// Simulate being at the limit by advancing MAX_CALL_ADVANCE - 1 times
// would be too slow. Instead, directly set step_count near the limit.
keys.step_count = MAX_CALL_ADVANCE - 1;
// One more advance should succeed (step_count == MAX_CALL_ADVANCE - 1 < MAX_CALL_ADVANCE).
assert!(keys.advance().is_ok());
// Now step_count == MAX_CALL_ADVANCE, next advance must fail.
assert!(matches!(keys.advance(), Err(Error::ChainExhausted)));
}
#[test]
fn advance_exhaustion_zeroizes_keys() {
let mut keys = derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_HI).unwrap();
keys.step_count = MAX_CALL_ADVANCE - 1;
keys.advance().unwrap();
// Exhaust the chain.
assert!(matches!(keys.advance(), Err(Error::ChainExhausted)));
// All keys must be zeroed after exhaustion.
assert_eq!(
*keys.send_key(),
[0u8; 32],
"send_key not zeroed after ChainExhausted"
);
assert_eq!(
*keys.recv_key(),
[0u8; 32],
"recv_key not zeroed after ChainExhausted"
);
#[allow(deprecated)]
{
assert_eq!(
*keys.chain_key_bytes(),
[0u8; 32],
"chain_key not zeroed after ChainExhausted"
);
}
}
#[test]
fn equal_fingerprints_rejected() {
assert!(matches!(
derive_call_keys(&RK, &SS, &CALL_ID, &FP_LO, &FP_LO),
Err(crate::error::Error::InvalidData)
));
}
#[test]
fn kdf_call_spec_kat() {
// Specification.md Appendix F.15 — KDF_Call reference vector.
use hex_literal::hex;
let root_key: [u8; 32] = [0xAAu8; 32];
let kem_ss: [u8; 32] = [0xBBu8; 32];
let call_id: [u8; 16] = [0xCCu8; 16];
let local_fp: [u8; 32] = [0x11u8; 32]; // < 0x22 → lower_role
let remote_fp: [u8; 32] = [0x22u8; 32];
let expected_key_a =
hex!("ed75d812373c9b3bf6bddd394a631950520503f103b492fb908621eb712b5970");
let expected_key_b =
hex!("c3e5171534e0d1f922ea4ebf318357b990eafb0fff45d8cf430639a1fe2bb1e4");
let expected_chain_key =
hex!("1427dde311aaa195b116cc98c870753179297981446d3b53e00a4a92a0d34aeb");
let keys = derive_call_keys(&root_key, &kem_ss, &call_id, &local_fp, &remote_fp).unwrap();
// local_fp < remote_fp → lower_role=true → send=key_a, recv=key_b.
assert_eq!(
keys.send_key().as_slice(),
&expected_key_a,
"send_key (key_a) does not match F.15 vector"
);
assert_eq!(
keys.recv_key().as_slice(),
&expected_key_b,
"recv_key (key_b) does not match F.15 vector"
);
#[allow(deprecated)]
{
assert_eq!(
keys.chain_key_bytes().as_slice(),
&expected_chain_key,
"chain_key does not match F.15 vector"
);
}
// Reversed-order coverage: swap fingerprints → same HKDF output,
// reversed role assignment.
let keys_rev =
derive_call_keys(&root_key, &kem_ss, &call_id, &remote_fp, &local_fp).unwrap();
assert_eq!(
keys_rev.send_key().as_slice(),
&expected_key_b,
"reversed: send_key should be key_b"
);
assert_eq!(
keys_rev.recv_key().as_slice(),
&expected_key_a,
"reversed: recv_key should be key_a"
);
}
#[test]
fn advance_call_chain_spec_kat() {
// Specification.md Appendix F.16 — AdvanceCallChain reference vector.
// Uses chain_key from F.15 as starting point.
use hex_literal::hex;
let root_key: [u8; 32] = [0xAAu8; 32];
let kem_ss: [u8; 32] = [0xBBu8; 32];
let call_id: [u8; 16] = [0xCCu8; 16];
let local_fp: [u8; 32] = [0x11u8; 32];
let remote_fp: [u8; 32] = [0x22u8; 32];
let expected_key_a_prime =
hex!("9cf3129c6bb7ad86cb12ffc534517a4c06a472fbcddbe295a501c79aa49800e1");
let expected_key_b_prime =
hex!("f24cd7822fd611159a6e6d809c6ac148fd7b9bad65d8b4f85745869634b2dd1e");
let expected_chain_key_prime =
hex!("d3ae610c39cd9f7f8dce990b5c91634092ad0621fc01b44b24b2cb9f3638d0f2");
let mut keys =
derive_call_keys(&root_key, &kem_ss, &call_id, &local_fp, &remote_fp).unwrap();
keys.advance().unwrap();
// lower_role=true → send=key_a', recv=key_b'.
assert_eq!(
keys.send_key().as_slice(),
&expected_key_a_prime,
"send_key (key_a') does not match F.16 vector"
);
assert_eq!(
keys.recv_key().as_slice(),
&expected_key_b_prime,
"recv_key (key_b') does not match F.16 vector"
);
#[allow(deprecated)]
{
assert_eq!(
keys.chain_key_bytes().as_slice(),
&expected_chain_key_prime,
"chain_key' does not match F.16 vector"
);
}
}
proptest::proptest! {
#[test]
fn proptest_derive_round_trip(
rk in proptest::array::uniform32(proptest::prelude::any::<u8>()),
ss in proptest::array::uniform32(proptest::prelude::any::<u8>()),
call_id in proptest::array::uniform16(proptest::prelude::any::<u8>()),
fp_a in proptest::array::uniform32(proptest::prelude::any::<u8>()),
fp_b in proptest::array::uniform32(proptest::prelude::any::<u8>()),
) {
// Filter out inputs rejected by derive_call_keys validation.
proptest::prop_assume!(fp_a != fp_b);
proptest::prop_assume!(rk != [0u8; 32]);
proptest::prop_assume!(ss != [0u8; 32]);
proptest::prop_assume!(call_id != [0u8; 16]);
let alice = derive_call_keys(&rk, &ss, &call_id, &fp_a, &fp_b).unwrap();
let bob = derive_call_keys(&rk, &ss, &call_id, &fp_b, &fp_a).unwrap();
proptest::prop_assert_eq!(alice.send_key(), bob.recv_key());
proptest::prop_assert_eq!(alice.recv_key(), bob.send_key());
}
}
}

290
soliton/src/constants.rs Normal file
View file

@ -0,0 +1,290 @@
//! Protocol constants from Soliton Crypto Appendix A.
/// HMAC label for KEM authentication (§4.2).
pub const AUTH_HMAC_LABEL: &[u8] = b"lo-auth-v1";
/// HKDF info prefix for LO-KEX key derivation (§5.4 Step 4).
pub const KEX_HKDF_INFO_PFX: &[u8] = b"lo-kex-v1";
/// Domain separator for SPK signatures (§5.3).
/// Prepended to the SPK public key before signing/verifying to prevent
/// cross-context signature reuse.
pub const SPK_SIG_LABEL: &[u8] = b"lo-spk-sig-v1";
/// Domain separator for SessionInit signatures (§5.4).
/// Prepended to the encoded SessionInit before Alice signs / Bob verifies to
/// bind the signature to the session-initiation context and prevent reuse in
/// other protocol components.
pub const INITIATOR_SIG_LABEL: &[u8] = b"lo-kex-init-sig-v1";
/// HKDF info for LO-Ratchet root KDF (§6.4).
pub const RATCHET_HKDF_INFO: &[u8] = b"lo-ratchet-v1";
/// AAD prefix for DM message encryption (§7.3).
pub const DM_AAD: &[u8] = b"lo-dm-v1";
/// AAD prefix for community storage encryption (§11.4.1).
pub const STORAGE_AAD: &[u8] = b"lo-storage-v1";
/// AAD prefix for DM queue storage encryption (§11.4.2).
pub const DM_QUEUE_AAD: &[u8] = b"lo-dm-queue-v1";
/// HKDF info for call key derivation (§6.12).
pub const CALL_HKDF_INFO: &[u8] = b"lo-call-v1";
/// Domain separation label for the initial verification phrase hash.
///
/// Used in SHA3-256 input: `"lo-verification-v1" || sorted_pk_a || sorted_pk_b`.
/// See the `verification` module for the full phrase generation algorithm.
pub const PHRASE_HASH_LABEL: &[u8] = b"lo-verification-v1";
/// Rehash label for verification phrase expansion.
///
/// Used to extend the initial 32-byte hash when more output bytes are needed
/// for word index derivation. See the `verification` module.
pub const PHRASE_EXPAND_LABEL: &[u8] = b"lo-phrase-expand-v1";
/// Call ID size: 16 bytes (128-bit random identifier generated by the call
/// initiator) (§6.12).
pub const CALL_ID_SIZE: usize = 16;
/// HMAC input byte for deriving the first call encryption key from the call
/// chain (§6.12). Distinct from the ratchet message key domain byte (0x01) to
/// prevent structural collision between the ratchet and call derivation
/// domains — security ultimately depends on different keys, but unique data
/// bytes provide defense-in-depth at zero cost.
pub const CALL_KEY_A_BYTE: &[u8] = &[0x04];
/// HMAC input byte for deriving the second call encryption key from the call
/// chain (§6.12).
pub const CALL_KEY_B_BYTE: &[u8] = &[0x05];
/// HMAC input byte for advancing the call chain key (§6.12). The call chain
/// derives three outputs per step (two call encryption keys + next chain key),
/// using 0x04/0x05/0x06 — disjoint from the ratchet message key domain byte
/// (0x01).
pub const CALL_CHAIN_ADV_BYTE: &[u8] = &[0x06];
// 0x02 and 0x03 are deliberately unassigned. The ratchet previously used
// 0x01/0x02 for chain-mode derivation; after the switch to counter-mode
// (epoch-key based), only 0x01 remains in use (MSG_KEY_DOMAIN_BYTE). The call
// chain uses 0x04/0x05/0x06. The gap is reserved to prevent accidental reuse
// by future extensions.
/// Domain byte prefix for counter-mode message key derivation (§6.3).
/// HMAC input is `0x01 || counter_be_u32` — the prefix provides domain
/// separation from any other potential HMAC use of the epoch key (defense-in-depth).
pub const MSG_KEY_DOMAIN_BYTE: u8 = 0x01;
/// Zero salt for HKDF in LO-KEX (§5.4 Step 4).
pub const HKDF_ZERO_SALT: [u8; 32] = [0u8; 32];
/// Maximum entries in recv_seen set per epoch (§6.7). Prevents memory
/// exhaustion from an adversary sending messages with many distinct counter
/// values. The set stores only 4-byte counters (not keys), so 65536 entries
/// is ~256 KB. Resets on each KEM ratchet step.
pub const MAX_RECV_SEEN: u32 = 65536;
/// Ratchet state serialization format version. Checked on deserialization;
/// states with a different version byte are rejected with `UnsupportedVersion`.
/// Bump when the wire format changes.
pub const RATCHET_BLOB_VERSION: u8 = 0x01;
/// Crypto version string.
pub const CRYPTO_VERSION: &str = "lo-crypto-v1";
// --- Key sizes ---
/// LO composite public key size: X-Wing (1216) + Ed25519 (32) + ML-DSA-65 (1952) = 3200 bytes.
pub const LO_PUBLIC_KEY_SIZE: usize = 3200;
/// LO composite secret key size: X-Wing (2432) + Ed25519 (32) + ML-DSA-65 seed (32) = 2496 bytes.
pub const LO_SECRET_KEY_SIZE: usize = 2496;
/// X-Wing secret key size: X25519 (32) + ML-KEM-768 (2400) = 2432 bytes.
pub const XWING_SECRET_KEY_SIZE: usize = 2432;
/// X-Wing public key size: X25519 (32) + ML-KEM-768 (1184) = 1216 bytes.
pub const XWING_PUBLIC_KEY_SIZE: usize = 1216;
/// X-Wing ciphertext size: 1120 bytes (X25519 ephemeral pk (32) + ML-KEM-768 ct (1088)).
pub const XWING_CIPHERTEXT_SIZE: usize = 1120;
/// Fingerprint size (raw SHA3-256): 32 bytes.
pub const FINGERPRINT_SIZE: usize = 32;
/// Ed25519 signature size: 64 bytes (RFC 8032).
pub const ED25519_SIGNATURE_SIZE: usize = 64;
/// ML-DSA-65 signature size: 3309 bytes (FIPS 204).
pub const MLDSA_SIGNATURE_SIZE: usize = 3309;
/// Hybrid signature size: Ed25519 (64) + ML-DSA-65 (3309) = 3373 bytes.
pub const HYBRID_SIGNATURE_SIZE: usize = 3373;
/// X-Wing shared secret size: 32 bytes.
pub const SHARED_SECRET_SIZE: usize = 32;
/// AEAD (XChaCha20-Poly1305) tag size: 16 bytes.
pub const AEAD_TAG_SIZE: usize = 16;
/// AEAD (XChaCha20-Poly1305) nonce size: 24 bytes.
pub const AEAD_NONCE_SIZE: usize = 24;
// --- Streaming AEAD constants ---
/// AAD domain label for streaming AEAD (§15).
pub const STREAM_AAD: &[u8] = b"lo-stream-v1";
/// Streaming AEAD chunk size: 1 MiB plaintext per non-final chunk.
/// Balance of memory use vs per-chunk overhead. Well under WASM 16 MiB cap.
pub const STREAM_CHUNK_SIZE: usize = 1_048_576;
/// Streaming AEAD header size: version (1) + flags (1) + base_nonce (24).
pub const STREAM_HEADER_SIZE: usize = 26;
/// Per-chunk wire overhead: tag_byte (1) + Poly1305 authentication tag (16).
pub const STREAM_CHUNK_OVERHEAD: usize = 17;
/// Worst-case zstd expansion for a `STREAM_CHUNK_SIZE` input.
/// Deliberate ~5× over-estimate of zstd stored-frame overhead (actual worst
/// case is ~50 bytes for 1 MiB). Cost of over-estimation: 256 bytes of extra
/// buffer allocation per chunk — negligible against 1 MiB chunks.
pub const STREAM_ZSTD_OVERHEAD: usize = 256;
/// Maximum encrypted chunk output size (with compression).
/// `STREAM_CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + STREAM_CHUNK_OVERHEAD`.
/// CAPI callers allocate output buffers of at least this size.
pub const STREAM_ENCRYPT_MAX: usize =
STREAM_CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + STREAM_CHUNK_OVERHEAD;
/// Streaming AEAD format version byte. Only `0x01` is currently defined.
pub const STREAM_VERSION: u8 = 0x01;
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::assertions_on_constants)]
fn constants_match_spec() {
// Protocol labels
assert_eq!(AUTH_HMAC_LABEL, b"lo-auth-v1");
assert_eq!(KEX_HKDF_INFO_PFX, b"lo-kex-v1");
assert_eq!(SPK_SIG_LABEL, b"lo-spk-sig-v1");
assert_eq!(INITIATOR_SIG_LABEL, b"lo-kex-init-sig-v1");
assert_eq!(RATCHET_HKDF_INFO, b"lo-ratchet-v1");
assert_eq!(DM_AAD, b"lo-dm-v1");
assert_eq!(STORAGE_AAD, b"lo-storage-v1");
assert_eq!(DM_QUEUE_AAD, b"lo-dm-queue-v1");
assert_eq!(CALL_HKDF_INFO, b"lo-call-v1");
assert_eq!(PHRASE_HASH_LABEL, b"lo-verification-v1");
assert_eq!(PHRASE_EXPAND_LABEL, b"lo-phrase-expand-v1");
// Call derivation bytes
assert_eq!(CALL_ID_SIZE, 16);
assert_eq!(CALL_KEY_A_BYTE, &[0x04]);
assert_eq!(CALL_KEY_B_BYTE, &[0x05]);
assert_eq!(CALL_CHAIN_ADV_BYTE, &[0x06]);
// Message key domain byte
assert_eq!(MSG_KEY_DOMAIN_BYTE, 0x01);
// HKDF zero salt
assert_eq!(HKDF_ZERO_SALT, [0u8; 32]);
// Ratchet limits
assert_eq!(MAX_RECV_SEEN, 65536);
// Composite key sizes
assert_eq!(LO_PUBLIC_KEY_SIZE, 3200);
assert_eq!(LO_SECRET_KEY_SIZE, 2496);
// X-Wing sizes
assert_eq!(XWING_SECRET_KEY_SIZE, 2432);
assert_eq!(XWING_PUBLIC_KEY_SIZE, 1216);
assert_eq!(XWING_CIPHERTEXT_SIZE, 1120);
// Other sizes
assert_eq!(FINGERPRINT_SIZE, 32);
assert_eq!(ED25519_SIGNATURE_SIZE, 64);
assert_eq!(MLDSA_SIGNATURE_SIZE, 3309);
assert_eq!(HYBRID_SIGNATURE_SIZE, 3373);
assert_eq!(SHARED_SECRET_SIZE, 32);
assert_eq!(AEAD_TAG_SIZE, 16);
assert_eq!(AEAD_NONCE_SIZE, 24);
// Streaming AEAD constants
assert_eq!(STREAM_AAD, b"lo-stream-v1");
assert_eq!(STREAM_CHUNK_SIZE, 1_048_576);
assert_eq!(STREAM_HEADER_SIZE, 26);
assert_eq!(STREAM_CHUNK_OVERHEAD, 17);
assert_eq!(STREAM_ZSTD_OVERHEAD, 256);
assert_eq!(
STREAM_ENCRYPT_MAX,
STREAM_CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + STREAM_CHUNK_OVERHEAD
);
assert_eq!(STREAM_VERSION, 0x01);
// Derived size consistency
assert_eq!(
HYBRID_SIGNATURE_SIZE,
ED25519_SIGNATURE_SIZE + MLDSA_SIGNATURE_SIZE
);
assert_eq!(LO_PUBLIC_KEY_SIZE, XWING_PUBLIC_KEY_SIZE + 32 + 1952);
assert_eq!(LO_SECRET_KEY_SIZE, XWING_SECRET_KEY_SIZE + 32 + 32);
// Version string
assert_eq!(CRYPTO_VERSION, "lo-crypto-v1");
// MAX_RECV_SEEN is bounded for memory safety
assert!(MAX_RECV_SEEN <= 65536);
}
#[test]
fn domain_labels_pairwise_unique() {
// All domain separation labels must be distinct to prevent
// cross-context collisions. Collect every label as bytes and
// verify no duplicates exist.
let labels: Vec<(&str, &[u8])> = vec![
("AUTH_HMAC_LABEL", AUTH_HMAC_LABEL),
("KEX_HKDF_INFO_PFX", KEX_HKDF_INFO_PFX),
("SPK_SIG_LABEL", SPK_SIG_LABEL),
("INITIATOR_SIG_LABEL", INITIATOR_SIG_LABEL),
("RATCHET_HKDF_INFO", RATCHET_HKDF_INFO),
("DM_AAD", DM_AAD),
("STORAGE_AAD", STORAGE_AAD),
("DM_QUEUE_AAD", DM_QUEUE_AAD),
("CALL_HKDF_INFO", CALL_HKDF_INFO),
("PHRASE_HASH_LABEL", PHRASE_HASH_LABEL),
("PHRASE_EXPAND_LABEL", PHRASE_EXPAND_LABEL),
("STREAM_AAD", STREAM_AAD),
];
for i in 0..labels.len() {
for j in (i + 1)..labels.len() {
assert_ne!(
labels[i].1, labels[j].1,
"domain label collision: {} == {}",
labels[i].0, labels[j].0
);
}
}
// Single-byte domain bytes must also be mutually distinct.
let domain_bytes: Vec<(&str, u8)> = vec![
("MSG_KEY_DOMAIN_BYTE", MSG_KEY_DOMAIN_BYTE),
("CALL_KEY_A_BYTE", CALL_KEY_A_BYTE[0]),
("CALL_KEY_B_BYTE", CALL_KEY_B_BYTE[0]),
("CALL_CHAIN_ADV_BYTE", CALL_CHAIN_ADV_BYTE[0]),
];
for i in 0..domain_bytes.len() {
for j in (i + 1)..domain_bytes.len() {
assert_ne!(
domain_bytes[i].1, domain_bytes[j].1,
"domain byte collision: {} == {}",
domain_bytes[i].0, domain_bytes[j].0
);
}
}
}
}

File diff suppressed because it is too large Load diff

107
soliton/src/error.rs Normal file
View file

@ -0,0 +1,107 @@
//! Error types for soliton.
/// All possible errors from soliton operations.
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// Input had an invalid size for the expected key/buffer type.
///
/// `expected` and `got` are safe to expose in Display because this variant
/// is only used for API-boundary size mismatches (e.g., "public key must be
/// 3200 bytes, got 100"). Parser-internal truncation errors use `InvalidData`
/// instead, to avoid leaking internal offsets (see RT-201).
#[error("invalid length: expected {expected}, got {got}")]
InvalidLength {
/// Required size.
expected: usize,
/// Actual size provided.
got: usize,
},
/// KEM decapsulation failed (X-Wing or ML-KEM internal error, or low-order
/// X25519 point in standalone DH).
#[error("decapsulation failed")]
DecapsulationFailed,
/// Signature verification failed (Ed25519, ML-DSA-65, or hybrid verify).
#[error("signature verification failed")]
VerificationFailed,
/// AEAD decryption failed (wrong key, tampered ciphertext, or wrong AAD).
#[error("AEAD decryption failed")]
AeadFailed,
/// Pre-key bundle verification failed (IK mismatch or invalid SPK signature).
#[error("pre-key bundle verification failed")]
BundleVerificationFailed,
/// Reserved — was skip cache overflow in the pre-counter-mode ratchet design.
///
/// Retained for CAPI ABI stability: `SolitonError::TooManySkipped = -6`
/// must not be reassigned. Not currently constructed by any code path.
#[error("too many skipped messages")]
TooManySkipped,
/// Duplicate or out-of-order message (already decrypted or behind recv_count).
#[error("duplicate message")]
DuplicateMessage,
/// Algorithm is disabled or unsupported.
///
/// Reserved for future use (e.g., algorithm deprecation). Not currently
/// constructed by any code path, but retained for CAPI ABI stability —
/// `SolitonError::AlgorithmDisabled = -9` must not be reassigned.
#[error("algorithm disabled")]
AlgorithmDisabled,
/// Serialized blob has unsupported version.
///
/// Payload intentionally omitted — in storage decryption, exposing the
/// version byte would let an attacker enumerate key versions in the keyring.
#[error("unsupported version")]
UnsupportedVersion,
/// Storage decompression failed.
///
/// Not currently constructed — all post-AEAD decompression failures are
/// mapped to `AeadFailed` to prevent error oracles. Retained for CAPI ABI
/// stability — `SolitonError::DecompressionFailed = -11` must not be
/// reassigned.
#[error("decompression failed")]
DecompressionFailed,
/// Storage blob has unsupported flags (reserved bits set).
///
/// Not currently constructed — reserved-flag rejections are mapped to
/// `AeadFailed` to prevent error oracles. Retained for CAPI ABI
/// stability — `SolitonError::UnsupportedFlags = -14` must not be reassigned.
#[error("unsupported storage flags")]
UnsupportedFlags,
/// Chain counter space exhausted.
///
/// Returned when a counter would exceed its maximum value: u32::MAX for
/// ratchet send/recv counters, 2^24 for call key advancement,
/// MAX_RECV_SEEN for the per-epoch duplicate detection set, or u64::MAX
/// for the streaming AEAD chunk index.
#[error("chain exhausted: counter space full")]
ChainExhausted,
/// Unsupported crypto version string in a pre-key bundle or session init.
/// The peer advertised a version this library does not implement.
#[error("unsupported crypto version")]
UnsupportedCryptoVersion,
/// Structurally invalid content: bad format markers, co-presence violations,
/// implausible field values, or semantic constraint failures.
#[error("invalid data")]
InvalidData,
/// Structurally unreachable internal invariant violated — receiving this
/// error indicates a bug in soliton.
#[error("internal error")]
Internal,
}
/// Result type alias for soliton operations.
pub type Result<T> = std::result::Result<T, Error>;

635
soliton/src/identity/mod.rs Normal file
View file

@ -0,0 +1,635 @@
//! LO composite key and hybrid signatures (§2, §3).
//!
//! LO composite key = X-Wing (X25519 + ML-KEM-768) + Ed25519 + ML-DSA-65.
//! Public key: 1216 + 32 + 1952 = 3200 bytes.
//! Secret key: 2432 + 32 + 32 (ML-DSA seed) = 2496 bytes.
//! Hybrid signature: Ed25519 (64) + ML-DSA-65 (3309) = 3373 bytes.
//!
//! ## Key Separation
//!
//! The X25519 key inside X-Wing is used exclusively for KEM (key agreement).
//! A separate Ed25519 keypair provides classical signing. This clean separation
//! means X25519 never participates in signing — no Montgomery↔Edwards
//! conversion needed, no dual-use security argument required.
use crate::constants;
use crate::error::{Error, Result};
use crate::primitives::{ed25519, mldsa, sha3_256, xwing};
use subtle::Choice;
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
/// LO composite public key (3200 bytes): X-Wing_pk || Ed25519_pk || ML-DSA-65_pk.
#[derive(Clone, Eq)]
pub struct IdentityPublicKey(pub(crate) Vec<u8>);
// Constant-time comparison: IdentityPublicKey may be compared against
// untrusted wire data (e.g., in verify_bundle). Variable-time memcmp
// would leak how many leading bytes match.
impl PartialEq for IdentityPublicKey {
fn eq(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq;
self.0.ct_eq(&other.0).into()
}
}
/// LO composite secret key: X-Wing_sk || Ed25519_sk || ML-DSA-65_sk.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct IdentitySecretKey(pub(crate) Vec<u8>);
/// Hybrid signature (3373 bytes): Ed25519_sig || ML-DSA-65_sig.
#[derive(Clone, PartialEq, Eq)]
pub struct HybridSignature(pub(crate) Vec<u8>);
impl IdentityPublicKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation only.
///
/// Accepts any buffer of exactly `LO_PUBLIC_KEY_SIZE` bytes — sub-key
/// structure (X-Wing, Ed25519, ML-DSA-65 components) is not validated here.
/// Invalid sub-key bytes will produce errors at point of use (encapsulate,
/// verify, etc.). This is intentional: eagerly parsing all sub-keys on
/// construction would add expensive ML-DSA key parsing to every deserialization.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != constants::LO_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_PUBLIC_KEY_SIZE,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Extract the X25519 public key (first 32 bytes, inside X-Wing).
pub fn x25519_pk(&self) -> &[u8] {
&self.0[..32]
}
/// Extract the ML-KEM-768 public key (bytes 32..1216, inside X-Wing).
pub fn mlkem_pk(&self) -> &[u8] {
&self.0[32..constants::XWING_PUBLIC_KEY_SIZE]
}
/// Extract the Ed25519 public key (bytes 1216..1248).
pub fn ed25519_pk(&self) -> &[u8] {
&self.0[constants::XWING_PUBLIC_KEY_SIZE..constants::XWING_PUBLIC_KEY_SIZE + 32]
}
/// Extract the ML-DSA-65 public key (bytes 1248..3200).
pub fn mldsa_pk(&self) -> &[u8] {
&self.0[constants::XWING_PUBLIC_KEY_SIZE + 32..]
}
/// Extract the X-Wing public key (first 1216 bytes).
pub fn xwing_pk(&self) -> &[u8] {
&self.0[..constants::XWING_PUBLIC_KEY_SIZE]
}
/// Compute the fingerprint (hex-encoded SHA3-256 of the full public key).
pub fn fingerprint_hex(&self) -> String {
sha3_256::fingerprint_hex(&self.0)
}
/// Compute the raw fingerprint (32-byte SHA3-256 of the full public key).
pub fn fingerprint_raw(&self) -> [u8; 32] {
sha3_256::hash(&self.0)
}
}
impl IdentitySecretKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
// Wrap in Zeroizing so the raw Vec is zeroized on the error path
// (IdentitySecretKey derives ZeroizeOnDrop, but that only fires on success).
let mut bytes = Zeroizing::new(bytes);
if bytes.len() != constants::LO_SECRET_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_SECRET_KEY_SIZE,
got: bytes.len(),
});
}
// mem::take moves the Vec out of the Zeroizing wrapper (replacing it
// with an empty Vec). The Zeroizing wrapper then drops the empty Vec
// harmlessly, while ownership of the key data transfers to Self.
Ok(Self(std::mem::take(&mut *bytes)))
}
/// Extract the X25519 secret key (first 32 bytes, inside X-Wing).
pub fn x25519_sk(&self) -> &[u8] {
&self.0[..32]
}
/// Extract the X-Wing secret key (first 2432 bytes): X25519_sk (32) || ML-KEM-768_sk (2400).
///
/// The boundary is `XWING_SECRET_KEY_SIZE = 2432`, a compile-time constant
/// enforced by `assert_eq!` in `generate_identity`.
pub fn xwing_sk(&self) -> &[u8] {
&self.0[..constants::XWING_SECRET_KEY_SIZE]
}
/// Extract the Ed25519 secret key (bytes 2432..2464).
pub fn ed25519_sk(&self) -> &[u8] {
&self.0[constants::XWING_SECRET_KEY_SIZE..constants::XWING_SECRET_KEY_SIZE + 32]
}
/// Extract the ML-DSA-65 seed `ξ` (32 bytes at offset 2464..2496, per FIPS 204 §6.1).
pub fn mldsa_sk(&self) -> &[u8] {
&self.0[constants::XWING_SECRET_KEY_SIZE + 32..]
}
}
impl HybridSignature {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != constants::HYBRID_SIGNATURE_SIZE {
return Err(Error::InvalidLength {
expected: constants::HYBRID_SIGNATURE_SIZE,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Extract the Ed25519 signature (first 64 bytes).
pub fn ed25519_sig(&self) -> &[u8] {
&self.0[..constants::ED25519_SIGNATURE_SIZE]
}
/// Extract the ML-DSA-65 signature (bytes 64..3373).
pub fn mldsa_sig(&self) -> &[u8] {
&self.0[constants::ED25519_SIGNATURE_SIZE..]
}
}
/// Return value of [`generate_identity`].
pub struct GeneratedIdentity {
/// LO composite public key (3200 bytes).
pub public_key: IdentityPublicKey,
/// LO composite secret key (2496 bytes). Zeroized on drop.
pub secret_key: IdentitySecretKey,
/// Lower-case hex SHA3-256 fingerprint of the public key (64 chars).
pub fingerprint_hex: String,
}
/// Generate a LO composite identity keypair.
///
/// # Security
///
/// The secret key Vec is wrapped in `Zeroizing` during construction and moved
/// into the `IdentitySecretKey` (which derives `ZeroizeOnDrop`). Intermediate
/// component keys are zeroized by their respective types on drop.
#[must_use = "identity key material must not be discarded"]
pub fn generate_identity() -> Result<GeneratedIdentity> {
// X-Wing first: its public key occupies the first 1216 bytes of the
// composite key, matching the layout expected by encapsulate/decapsulate.
let (xwing_pk, xwing_sk) = xwing::keygen()?;
// Panic (not error return): size mismatch indicates a build-time constant
// bug or dependency update that changed type-level sizes — not a runtime
// input error that a caller could recover from.
assert_eq!(
xwing_sk.as_bytes().len(),
constants::XWING_SECRET_KEY_SIZE,
"X-Wing secret key size mismatch — update XWING_SECRET_KEY_SIZE in constants.rs"
);
// Ed25519: separate signing keypair. Not derived from X-Wing's X25519 key.
let (ed_vk, ed_sk) = ed25519::keygen();
assert_eq!(ed_vk.as_bytes().len(), 32, "Ed25519 PK must be 32 bytes");
let (mldsa_pk, mldsa_sk) = mldsa::keygen()?;
assert_eq!(
mldsa_sk.as_bytes().len(),
32,
"ML-DSA-65 seed size mismatch — expected 32 bytes"
);
// PK layout: X-Wing (1216) || Ed25519 (32) || ML-DSA-65 (1952)
let mut pk = Vec::with_capacity(xwing_pk.as_bytes().len() + 32 + mldsa_pk.as_bytes().len());
pk.extend_from_slice(xwing_pk.as_bytes());
pk.extend_from_slice(ed_vk.as_bytes());
pk.extend_from_slice(mldsa_pk.as_bytes());
// SK layout: X-Wing (2432) || Ed25519 (32) || ML-DSA seed (32)
let mut sk = Zeroizing::new(Vec::with_capacity(
xwing_sk.as_bytes().len() + 32 + mldsa_sk.as_bytes().len(),
));
sk.extend_from_slice(xwing_sk.as_bytes());
sk.extend_from_slice(ed_sk.as_bytes());
sk.extend_from_slice(mldsa_sk.as_bytes());
// ed_sk is ZeroizeOnDrop — drops automatically when this scope ends.
let fingerprint = sha3_256::fingerprint_hex(&pk);
// mem::take moves the Vec out of the Zeroizing wrapper (replacing it with
// an empty Vec). The Zeroizing wrapper drops the empty Vec harmlessly.
Ok(GeneratedIdentity {
public_key: IdentityPublicKey(pk),
secret_key: IdentitySecretKey(std::mem::take(&mut *sk)),
fingerprint_hex: fingerprint,
})
}
/// Sign a message with both Ed25519 and ML-DSA-65 (§3.1).
///
/// Both signatures must verify for the hybrid signature to be valid.
///
/// # Security
///
/// The Ed25519 signing key is reconstructed from the composite SK bytes via
/// `SigningKey::from_bytes`, which copies the 32-byte seed. The `SigningKey`
/// implements `ZeroizeOnDrop` and is cleaned up when it goes out of scope.
/// The ML-DSA seed copy is wrapped in `Zeroizing` and zeroized on drop.
pub fn hybrid_sign(sk: &IdentitySecretKey, message: &[u8]) -> Result<HybridSignature> {
// Ed25519: sign with the dedicated Ed25519 secret key.
let ed_sk_bytes: &[u8; 32] = sk.ed25519_sk().try_into().map_err(|_| Error::Internal)?;
let signing_key = ed25519_dalek::SigningKey::from_bytes(ed_sk_bytes);
let sig_classical = ed25519::sign(&signing_key, message);
// signing_key is ZeroizeOnDrop — cleaned up when it drops here.
// ML-DSA-65: sign with the ML-DSA seed.
// Wrap .to_vec() in Zeroizing so the intermediate heap copy is zeroized
// regardless of the panic strategy configured in Cargo.toml.
let mut sk_bytes = Zeroizing::new(sk.mldsa_sk().to_vec());
let mldsa_sk = mldsa::SecretKey::from_bytes(std::mem::take(&mut *sk_bytes))?;
let sig_pqc = mldsa::sign(&mldsa_sk, message)?;
// Debug-only: ML-DSA signature size is a crate-level constant, verified by
// runtime asserts in mldsa::sign(). This is a defense-in-depth sanity check.
debug_assert_eq!(sig_pqc.as_bytes().len(), constants::MLDSA_SIGNATURE_SIZE);
let mut sig = Vec::with_capacity(constants::ED25519_SIGNATURE_SIZE + sig_pqc.as_bytes().len());
sig.extend_from_slice(&sig_classical);
sig.extend_from_slice(sig_pqc.as_bytes());
Ok(HybridSignature(sig))
}
#[must_use = "signature verification result must be checked"]
/// Verify a hybrid signature (§3.2).
///
/// Both Ed25519 and ML-DSA-65 components must verify.
///
/// # Security
///
/// Both components are computed eagerly into `r1` and `r2`, then combined
/// with a constant-time `Choice` AND — the branch that selects Ok vs Err
/// does not reveal which component failed.
pub fn hybrid_verify(pk: &IdentityPublicKey, message: &[u8], sig: &HybridSignature) -> Result<()> {
let sig_classical = sig.ed25519_sig();
let sig_pqc_bytes = sig.mldsa_sig();
// Ed25519: verify with the dedicated Ed25519 public key.
let ed_sig: &[u8; 64] = sig_classical.try_into().map_err(|_| Error::Internal)?;
let ed_pk_bytes: &[u8; 32] = pk.ed25519_pk().try_into().map_err(|_| Error::Internal)?;
let r1 = ed25519_dalek::VerifyingKey::from_bytes(ed_pk_bytes)
.map_err(|_| Error::VerificationFailed)
.and_then(|vk| ed25519::verify(&vk, message, ed_sig));
let mldsa_pk = mldsa::PublicKey::from_bytes_unchecked(pk.mldsa_pk().to_vec());
let mldsa_sig = mldsa::Signature::from_bytes_unchecked(sig_pqc_bytes.to_vec());
let r2 = mldsa::verify(&mldsa_pk, message, &mldsa_sig);
// Constant-time combination: both results are evaluated eagerly above,
// and the ok/err decision uses subtle::Choice to avoid leaking which
// component failed via timing.
let ok1 = Choice::from(u8::from(r1.is_ok()));
let ok2 = Choice::from(u8::from(r2.is_ok()));
if bool::from(ok1 & ok2) {
Ok(())
} else {
Err(Error::VerificationFailed)
}
}
/// Encapsulate to a LO composite key's X-Wing component (§2.3).
///
/// Extracts the X-Wing public key from the composite key and delegates to
/// `xwing::encapsulate`. Size is guaranteed by `IdentityPublicKey`'s constructor,
/// so `from_bytes_unchecked` is safe.
///
/// # Security
///
/// The returned `SharedSecret` contains raw key material. The caller is
/// responsible for deriving session keys (e.g., via HKDF) and ensuring the
/// shared secret is zeroized when no longer needed.
pub fn encapsulate(pk: &IdentityPublicKey) -> Result<(xwing::Ciphertext, xwing::SharedSecret)> {
let xwing_pk = xwing::PublicKey::from_bytes_unchecked(pk.xwing_pk().to_vec());
xwing::encapsulate(&xwing_pk)
}
/// Decapsulate using a LO composite key's X-Wing component (§2.3).
///
/// `ct`: X-Wing ciphertext produced by [`encapsulate`].
///
/// # Security
///
/// The returned `SharedSecret` contains raw key material. The caller is
/// responsible for deriving session keys (e.g., via HKDF) and ensuring the
/// shared secret is zeroized when no longer needed.
pub fn decapsulate(sk: &IdentitySecretKey, ct: &xwing::Ciphertext) -> Result<xwing::SharedSecret> {
// .to_vec() creates a heap copy of the X-Wing secret key slice;
// xwing::SecretKey takes ownership and zeroizes the copy on drop.
let xwing_sk = xwing::SecretKey::from_bytes_unchecked(sk.xwing_sk().to_vec());
xwing::decapsulate(&xwing_sk, ct)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[test]
fn generate_identity_sizes() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
assert_eq!(pk.as_bytes().len(), constants::LO_PUBLIC_KEY_SIZE);
assert_eq!(sk.as_bytes().len(), constants::LO_SECRET_KEY_SIZE);
}
#[test]
fn fingerprint_format() {
let GeneratedIdentity {
public_key: pk,
fingerprint_hex: fp,
..
} = generate_identity().unwrap();
assert_eq!(fp.len(), 64);
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
// Accessor must match returned fingerprint.
assert_eq!(pk.fingerprint_hex(), fp);
}
#[test]
fn fingerprint_matches_pk() {
let GeneratedIdentity {
public_key: pk,
fingerprint_hex: fp,
..
} = generate_identity().unwrap();
let expected = sha3_256::fingerprint_hex(pk.as_bytes());
assert_eq!(fp, expected);
}
#[test]
fn pk_component_extraction() {
let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
let bytes = pk.as_bytes();
assert_eq!(pk.x25519_pk(), &bytes[..32]);
assert_eq!(pk.mlkem_pk(), &bytes[32..constants::XWING_PUBLIC_KEY_SIZE]);
assert_eq!(
pk.ed25519_pk(),
&bytes[constants::XWING_PUBLIC_KEY_SIZE..constants::XWING_PUBLIC_KEY_SIZE + 32]
);
assert_eq!(
pk.mldsa_pk(),
&bytes[constants::XWING_PUBLIC_KEY_SIZE + 32..]
);
// Sizes
assert_eq!(pk.x25519_pk().len(), 32);
assert_eq!(pk.mlkem_pk().len(), 1184);
assert_eq!(pk.ed25519_pk().len(), 32);
assert_eq!(pk.mldsa_pk().len(), 1952);
}
#[test]
fn xwing_pk_accessor() {
let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
assert_eq!(
pk.xwing_pk(),
&pk.as_bytes()[..constants::XWING_PUBLIC_KEY_SIZE]
);
assert_eq!(pk.xwing_pk().len(), 1216);
}
#[test]
fn fingerprint_raw_accessor() {
let GeneratedIdentity { public_key: pk, .. } = generate_identity().unwrap();
let raw = pk.fingerprint_raw();
assert_eq!(raw.len(), 32);
assert_eq!(raw, sha3_256::hash(pk.as_bytes()));
}
#[test]
fn hybrid_sign_verify_round_trip() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test message").unwrap();
assert!(hybrid_verify(&pk, b"test message", &sig).is_ok());
}
#[test]
fn hybrid_sign_size() {
let GeneratedIdentity { secret_key: sk, .. } = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
assert_eq!(sig.as_bytes().len(), constants::HYBRID_SIGNATURE_SIZE);
assert_eq!(sig.as_bytes().len(), 3373);
}
#[test]
fn hybrid_verify_wrong_message() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"message one").unwrap();
assert!(matches!(
hybrid_verify(&pk, b"message two", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_verify_wrong_key() {
let GeneratedIdentity { secret_key: sk, .. } = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk2, ..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
assert!(matches!(
hybrid_verify(&pk2, b"test", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_verify_tampered_ed25519() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
let mut bad = sig.as_bytes().to_vec();
bad[0] ^= 0xFF; // flip byte in Ed25519 portion
let bad_sig = HybridSignature::from_bytes(bad).unwrap();
assert!(matches!(
hybrid_verify(&pk, b"test", &bad_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_verify_tampered_mldsa() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
let mut bad = sig.as_bytes().to_vec();
bad[64] ^= 0xFF; // flip byte in ML-DSA portion
let bad_sig = HybridSignature::from_bytes(bad).unwrap();
assert!(matches!(
hybrid_verify(&pk, b"test", &bad_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_verify_valid_ed25519_invalid_mldsa() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
// Keep valid Ed25519, replace ML-DSA with zeros.
let mut franken = sig.as_bytes()[..64].to_vec();
franken.extend_from_slice(&vec![0u8; constants::MLDSA_SIGNATURE_SIZE]);
let bad_sig = HybridSignature::from_bytes(franken).unwrap();
assert!(matches!(
hybrid_verify(&pk, b"test", &bad_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_verify_invalid_ed25519_valid_mldsa() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig = hybrid_sign(&sk, b"test").unwrap();
// Replace Ed25519 with zeros, keep valid ML-DSA.
let mut franken = vec![0u8; 64];
franken.extend_from_slice(&sig.as_bytes()[64..]);
let bad_sig = HybridSignature::from_bytes(franken).unwrap();
assert!(matches!(
hybrid_verify(&pk, b"test", &bad_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn encapsulate_decapsulate() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
// Split into 3 separate tests: under panic="abort" (workspace-wide), a failure
// in the first assertion terminates the process, making subsequent assertions
// unreachable and untested.
#[test]
fn identity_public_key_from_bytes_wrong_size() {
assert!(matches!(
IdentityPublicKey::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 3200,
got: 100
})
));
}
#[test]
fn identity_secret_key_from_bytes_wrong_size() {
assert!(matches!(
IdentitySecretKey::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 2496,
got: 100
})
));
}
#[test]
fn hybrid_signature_from_bytes_wrong_size() {
assert!(matches!(
HybridSignature::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 3373,
got: 100
})
));
}
#[test]
fn hybrid_verify_cross_spliced_signatures_rejected() {
let GeneratedIdentity {
public_key: pk,
secret_key: sk,
..
} = generate_identity().unwrap();
let sig_a = hybrid_sign(&sk, b"message A").unwrap();
let sig_b = hybrid_sign(&sk, b"message B").unwrap();
// Cross-splice: Ed25519 from sig_a (valid for "message A") with
// ML-DSA from sig_b (valid for "message B"). A short-circuit bug
// that checks only one component would accept one of the messages.
let mut spliced = sig_a.as_bytes()[..constants::ED25519_SIGNATURE_SIZE].to_vec();
spliced.extend_from_slice(&sig_b.as_bytes()[constants::ED25519_SIGNATURE_SIZE..]);
let franken_sig = HybridSignature::from_bytes(spliced).unwrap();
assert!(matches!(
hybrid_verify(&pk, b"message A", &franken_sig),
Err(Error::VerificationFailed)
));
assert!(matches!(
hybrid_verify(&pk, b"message B", &franken_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn hybrid_sign_nondeterministic() {
// ML-DSA uses hedged signing — two signatures over the same message
// must produce different ML-DSA components (different randomness).
// Ed25519 is deterministic (RFC 8032), so its component is identical.
let GeneratedIdentity { secret_key: sk, .. } = generate_identity().unwrap();
let msg = b"same message";
let sig1 = hybrid_sign(&sk, msg).unwrap();
let sig2 = hybrid_sign(&sk, msg).unwrap();
// Ed25519 component (first 64 bytes) is deterministic.
assert_eq!(&sig1.as_bytes()[..64], &sig2.as_bytes()[..64]);
// ML-DSA component (bytes 64..) must differ (hedged signing).
assert_ne!(&sig1.as_bytes()[64..], &sig2.as_bytes()[64..]);
}
}

2064
soliton/src/kex/mod.rs Normal file

File diff suppressed because it is too large Load diff

57
soliton/src/lib.rs Normal file
View file

@ -0,0 +1,57 @@
#![forbid(unsafe_code)]
#![deny(missing_docs)]
#![deny(clippy::cast_possible_truncation)]
//! # soliton
//!
//! Core cryptographic library for the LO protocol.
//!
//! Provides all cryptographic operations specified in Soliton Specification:
//! - Soliton composite key (X-Wing + Ed25519 + ML-DSA-65) generation and management
//! - Hybrid signatures (Ed25519 + ML-DSA-65)
//! - KEM-based authentication
//! - LO-KEX key agreement (session initiation and reception)
//! - LO-Ratchet (KEM ratchet + symmetric chain) message encryption
//! - Storage encryption (XChaCha20-Poly1305 + zstd)
//!
//! ## Backend
//!
//! Pure Rust on all targets (native and WASM):
//! - **RustCrypto**: ML-KEM-768, ML-DSA-65, XChaCha20-Poly1305, SHA3-256, HMAC, HKDF
//! - **curve25519-dalek / ed25519-dalek**: X25519, Ed25519
//! - **getrandom**: CSPRNG
// Prevent `test-utils` from being enabled in release builds.
// The feature gates test-only helpers (zeroed key constructors, state inspection)
// that must never be available outside of test/development contexts.
#[cfg(all(feature = "test-utils", not(debug_assertions)))]
compile_error!(
"The `test-utils` feature must not be enabled in release builds. \
It exposes internal state constructors that bypass security invariants."
);
/// Library version, matching the crate version from Cargo.toml.
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
/// Protocol constants (key sizes, HKDF labels, version strings).
pub mod constants;
/// Error types for all soliton operations.
pub mod error;
/// KEM-based authentication challenge/response (§4).
pub mod auth;
/// E2EE voice call key derivation (§6.12).
pub mod call;
/// LO composite identity key and hybrid signature operations (§2, §3).
pub mod identity;
/// LO-KEX session key agreement (§5).
pub mod kex;
/// Low-level cryptographic primitives (AEAD, KEM, signatures, hashing, RNG).
pub mod primitives;
/// LO-Ratchet and message encryption (§6, §7).
pub mod ratchet;
/// Server-side storage encryption with key rotation (§11).
pub mod storage;
/// Streaming/chunked AEAD for large payloads (§15).
pub mod streaming;
/// Verification phrase generation for out-of-band identity verification (§9).
pub mod verification;

View file

@ -0,0 +1,258 @@
//! XChaCha20-Poly1305 authenticated encryption (RFC 8439 + HChaCha20 extension).
//!
//! Constant-time by construction — ChaCha20 uses only ARX operations (add,
//! rotate, xor), so there are no secret-dependent table lookups or cache-timing
//! channels regardless of hardware. The `chacha20poly1305` crate provides the
//! XChaCha20 variant with 24-byte nonces (birthday bound ~2^96 vs ~2^48 for
//! 12-byte nonces).
use crate::error::{Error, Result};
use zeroize::Zeroizing;
use chacha20poly1305::aead::AeadInPlace;
use chacha20poly1305::{KeyInit, Tag, XChaCha20Poly1305, XNonce};
/// Poly1305 authentication tag size (bytes).
const TAG_LEN: usize = 16;
/// Encrypt plaintext with XChaCha20-Poly1305.
///
/// Returns ciphertext || 16-byte tag as a plain `Vec<u8>`. Ciphertext is not
/// secret material, so no `Zeroizing` wrapper (unlike `aead_decrypt`).
///
/// # Security
///
/// The plaintext staging buffer is wrapped in `Zeroizing` and zeroized on error.
/// After in-place encryption, the buffer contains only ciphertext (non-secret),
/// so `mem::take` safely extracts it.
#[must_use = "ciphertext must be transmitted or stored"]
#[allow(clippy::explicit_auto_deref)] // &mut *buffer makes the Zeroizing deref explicit and visible
pub fn aead_encrypt(
key: &[u8; 32],
nonce: &[u8; 24],
plaintext: &[u8],
aad: &[u8],
) -> Result<Vec<u8>> {
let cipher = XChaCha20Poly1305::new(key.into());
let nonce = XNonce::from_slice(nonce);
// Zeroizing wrapper ensures plaintext is zeroized on the error path.
// Pre-allocate for plaintext + TAG_LEN-byte Poly1305 tag to avoid reallocation.
let mut buffer = Zeroizing::new({
let cap = plaintext
.len()
.checked_add(TAG_LEN)
.ok_or(Error::AeadFailed)?;
let mut v = Vec::with_capacity(cap);
v.extend_from_slice(plaintext);
v
});
// chacha20poly1305 encrypt can only error if the buffer exceeds the
// cipher's internal length limit. AeadFailed is correct — this is not a
// caller-size error but a cipher implementation limit.
let tag = cipher
.encrypt_in_place_detached(nonce, aad, &mut *buffer)
.map_err(|_| Error::AeadFailed)?;
// After encryption, *buffer contains ciphertext (not plaintext) — Zeroizing's
// job is done. extend_from_slice won't reallocate (capacity pre-reserved).
// mem::take extracts the Vec; Zeroizing drops an empty Vec.
// Capacity is pre-reserved above; this cannot fail in correct code.
// Debug-only because it guards an allocation invariant, not a security property.
debug_assert!(
buffer.capacity() >= buffer.len() + TAG_LEN,
"aead_encrypt: unexpected capacity — reallocation would bypass Zeroizing"
);
buffer.extend_from_slice(&tag);
Ok(std::mem::take(&mut *buffer))
}
/// Decrypt an XChaCha20-Poly1305 ciphertext.
///
/// `ciphertext` must include the 16-byte authentication tag.
/// Returns self-zeroizing plaintext on success, `Error::AeadFailed` if
/// authentication fails.
///
/// # Security
///
/// The returned plaintext is wrapped in `Zeroizing<Vec<u8>>` — it is
/// automatically zeroized on drop. On authentication failure, the partially
/// decrypted buffer is also zeroized by the `Zeroizing` wrapper's `Drop` impl.
#[must_use = "decrypted plaintext must be consumed or zeroized"]
#[allow(clippy::explicit_auto_deref)] // &mut *buffer makes the Zeroizing deref explicit and visible
pub fn aead_decrypt(
key: &[u8; 32],
nonce: &[u8; 24],
ciphertext: &[u8],
aad: &[u8],
) -> Result<Zeroizing<Vec<u8>>> {
// AeadFailed (not InvalidLength) to avoid leaking whether the ciphertext
// was "too short" vs "bad tag" — both indicate tampered or truncated data.
if ciphertext.len() < TAG_LEN {
return Err(Error::AeadFailed);
}
let cipher = XChaCha20Poly1305::new(key.into());
let nonce = XNonce::from_slice(nonce);
let ct_len = ciphertext.len() - TAG_LEN;
let mut buffer = Zeroizing::new(ciphertext[..ct_len].to_vec());
let tag = Tag::from_slice(&ciphertext[ct_len..]);
// On authentication failure, Zeroizing drop zeroizes the partially decrypted buffer.
cipher
.decrypt_in_place_detached(nonce, aad, &mut *buffer, tag)
.map_err(|_| Error::AeadFailed)?;
Ok(buffer)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use hex_literal::hex;
/// Deterministic consistency test: encrypt known inputs, verify output is
/// stable and round-trips correctly. If this test breaks, the AEAD
/// implementation has changed behavior — all persisted ciphertexts would
/// become undecryptable.
#[test]
fn deterministic_consistency() {
let key = hex!("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
let nonce = hex!("000000000000000000000000000000000000000000000001");
let pt = b"soliton-aead-kat";
let aad = b"lo-test-v1";
let ct1 = aead_encrypt(&key, &nonce, pt, aad).unwrap();
let ct2 = aead_encrypt(&key, &nonce, pt, aad).unwrap();
// Same inputs must produce identical ciphertext (XChaCha20-Poly1305 is deterministic).
assert_eq!(ct1, ct2);
// Ciphertext must be plaintext_len + 16-byte tag.
assert_eq!(ct1.len(), pt.len() + 16);
// Must round-trip.
let decrypted = aead_decrypt(&key, &nonce, &ct1, aad).unwrap();
assert_eq!(&*decrypted, pt);
}
#[test]
fn round_trip() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let plaintext = b"round trip test";
let ct = aead_encrypt(&key, &nonce, plaintext, b"").unwrap();
let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
assert_eq!(&*pt, plaintext);
}
#[test]
fn round_trip_with_aad() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let aad = b"additional data";
let ct = aead_encrypt(&key, &nonce, b"secret", aad).unwrap();
let pt = aead_decrypt(&key, &nonce, &ct, aad).unwrap();
assert_eq!(&*pt, b"secret");
}
#[test]
fn round_trip_empty_plaintext() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let ct = aead_encrypt(&key, &nonce, b"", b"").unwrap();
assert_eq!(ct.len(), 16); // tag only
let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
assert!(pt.is_empty());
}
#[test]
fn round_trip_large_plaintext() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let plaintext = vec![0xABu8; 65536];
let ct = aead_encrypt(&key, &nonce, &plaintext, b"").unwrap();
let pt = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
assert_eq!(&*pt, &plaintext);
}
#[test]
fn tampered_ciphertext() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let mut ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
ct[0] ^= 0xFF; // flip byte in CT body
assert!(matches!(
aead_decrypt(&key, &nonce, &ct, b""),
Err(Error::AeadFailed)
));
}
#[test]
fn tampered_tag() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let mut ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
let last = ct.len() - 1;
ct[last] ^= 0xFF; // flip byte in tag
assert!(matches!(
aead_decrypt(&key, &nonce, &ct, b""),
Err(Error::AeadFailed)
));
}
#[test]
fn wrong_key() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
let wrong_key: [u8; 32] = crate::primitives::random::random_array();
assert!(matches!(
aead_decrypt(&wrong_key, &nonce, &ct, b""),
Err(Error::AeadFailed)
));
}
#[test]
fn wrong_nonce() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let ct = aead_encrypt(&key, &nonce, b"plaintext", b"").unwrap();
let wrong_nonce: [u8; 24] = crate::primitives::random::random_array();
assert!(matches!(
aead_decrypt(&key, &wrong_nonce, &ct, b""),
Err(Error::AeadFailed)
));
}
#[test]
fn wrong_aad() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let ct = aead_encrypt(&key, &nonce, b"plaintext", b"correct").unwrap();
assert!(matches!(
aead_decrypt(&key, &nonce, &ct, b"wrong"),
Err(Error::AeadFailed)
));
}
#[test]
fn too_short_ciphertext() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let short = vec![0u8; 15]; // < 16 byte tag
assert!(matches!(
aead_decrypt(&key, &nonce, &short, b""),
Err(Error::AeadFailed)
));
}
#[test]
fn decrypt_returns_zeroizing() {
let key: [u8; 32] = crate::primitives::random::random_array();
let nonce: [u8; 24] = crate::primitives::random::random_array();
let ct = aead_encrypt(&key, &nonce, b"test", b"").unwrap();
// Compile-time type check: result is Zeroizing<Vec<u8>>.
let result: Zeroizing<Vec<u8>> = aead_decrypt(&key, &nonce, &ct, b"").unwrap();
assert_eq!(&*result, b"test");
}
}

View file

@ -0,0 +1,402 @@
//! Argon2id password-based key derivation (RFC 9106).
//!
//! Provides a thin, safe wrapper over the `argon2` crate (RustCrypto).
//! Pure Rust — no C FFI, no system library dependencies.
use argon2::{Algorithm, Argon2, Params, Version};
use zeroize::Zeroize;
use crate::error::{Error, Result};
/// Cost parameters for Argon2id key derivation.
///
/// Controls the computational cost of key derivation. Higher values are more
/// resistant to brute-force attacks but take longer to compute.
#[derive(Debug, Clone, Copy)]
pub struct Argon2Params {
/// Memory cost in KiB (minimum 8; OWASP minimum: 19456 = 19 MiB).
pub m_cost: u32,
/// Time cost — number of passes over memory (minimum 1).
pub t_cost: u32,
/// Parallelism — number of independent memory lanes (minimum 1).
pub p_cost: u32,
}
impl Argon2Params {
/// OWASP recommended minimum: 19 MiB, 2 passes, 1 lane.
///
/// Suitable for interactive authentication with a latency budget below 1 s.
/// Use [`RECOMMENDED`](Self::RECOMMENDED) for stored key material where
/// latency is less critical.
pub const OWASP_MIN: Self = Self {
m_cost: 19 * 1024,
t_cost: 2,
p_cost: 1,
};
/// Conservative default for locally-stored keypair protection: 64 MiB, 3 passes, 4 lanes.
///
/// Appropriate for passphrase-protected identity keypairs stored on-device.
/// Runs in roughly 0.1-1 s on modern hardware.
///
/// **Not suitable for WASM targets** — 64 MiB allocation will OOM-abort on
/// most WASM runtimes (default linear memory ~256 MiB). Use
/// [`WASM_DEFAULT`](Self::WASM_DEFAULT) instead.
pub const RECOMMENDED: Self = Self {
m_cost: 64 * 1024,
t_cost: 3,
p_cost: 4,
};
/// WASM-safe default: 16 MiB, 3 passes, 1 lane.
///
/// WASM linear memory defaults to ~256 MiB maximum. The 64 MiB
/// [`RECOMMENDED`](Self::RECOMMENDED) parameters risk OOM on constrained
/// runtimes. This preset reduces memory to 16 MiB while compensating with
/// the same pass count and single-lane execution (WASM is single-threaded).
pub const WASM_DEFAULT: Self = Self {
m_cost: 16 * 1024,
t_cost: 3,
p_cost: 1,
};
}
/// Derive key material from a passphrase using Argon2id (RFC 9106).
///
/// # Arguments
///
/// * `password` — passphrase bytes (UTF-8 recommended; any byte sequence accepted).
/// May be empty.
/// * `salt` — random salt. Must be at least 8 bytes; 16 or 32 random bytes recommended.
/// Use [`crate::primitives::random::random_array`] to generate.
/// * `params` — Argon2id cost parameters. Use [`Argon2Params::RECOMMENDED`] for
/// keypair protection.
/// * `out` — caller-allocated output buffer; receives derived key material on success.
/// Must be at least 1 byte and at most 4096 bytes. Typically 32 bytes for a 256-bit symmetric key.
///
/// # Security
///
/// The caller is responsible for zeroizing `out` when the derived key material is
/// no longer needed — use `zeroize::Zeroize::zeroize(&mut out)` or wrap `out` in
/// `zeroize::Zeroizing`. The `argon2` crate's internal working memory blocks are
/// zeroized on drop (enabled via the `zeroize` feature). The `password` slice is
/// never copied or retained after this call returns.
///
/// # Errors
///
/// Returns [`Error::InvalidLength`] if `salt` is shorter than 8 bytes, or `out` is
/// empty or exceeds 4096 bytes.
/// Returns [`Error::InvalidData`] if cost params exceed upper bounds (`m_cost > 4_194_304`,
/// `t_cost > 256`, `p_cost > 256`) or violate argon2 library minimums (e.g. `m_cost`
/// below the library minimum of 8 KiB, or `t_cost` / `p_cost` below 1).
pub fn argon2id(password: &[u8], salt: &[u8], params: Argon2Params, out: &mut [u8]) -> Result<()> {
const SALT_MIN: usize = 8;
// Output cap: 4096 bytes (32768 bits). No realistic use case exceeds a few
// hundred bytes (typical: 32 for a symmetric key). Prevents multi-GiB
// allocation from untrusted output length. Matches the CAPI cap.
const OUTPUT_MAX: usize = 4096;
// Upper bounds prevent DoS from untrusted parameters. Matches the CAPI caps.
// m_cost: 4 GiB (4_194_304 KiB) — no realistic use case exceeds single-digit GiB.
// t_cost: 256 passes — far beyond any recommendation (OWASP: 2-3).
// p_cost: 256 lanes — far beyond any recommendation (typical: 1-8).
const M_COST_MAX: u32 = 4_194_304;
const T_COST_MAX: u32 = 256;
const P_COST_MAX: u32 = 256;
if salt.len() < SALT_MIN {
return Err(Error::InvalidLength {
expected: SALT_MIN,
got: salt.len(),
});
}
// Empty output violates the minimum (1 byte) — `expected` reflects the minimum bound
// violated, not the maximum. Oversized output violates the maximum (4096 bytes) —
// `expected` reflects the maximum. Two separate branches so `expected` always names
// the specific bound that was crossed. (§10.6: "expected reflects the bound violated.")
if out.is_empty() {
return Err(Error::InvalidLength {
expected: 1,
got: 0,
});
}
if out.len() > OUTPUT_MAX {
return Err(Error::InvalidLength {
expected: OUTPUT_MAX,
got: out.len(),
});
}
// Cost parameters are semantic values, not lengths — InvalidData is correct
// per the error taxonomy (InvalidLength = wrong-size parameter).
if params.m_cost > M_COST_MAX || params.t_cost > T_COST_MAX || params.p_cost > P_COST_MAX {
return Err(Error::InvalidData);
}
let argon2_params = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(out.len()))
.map_err(|_| Error::InvalidData)?;
let result = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params)
.hash_password_into(password, salt, out);
// Zeroize on failure: hash_password_into may have written partial key material.
if result.is_err() {
out.zeroize();
}
result.map_err(|_| Error::Internal)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
/// Tiny parameters for fast unit tests.
///
/// m=8 KiB (minimum), t=1, p=1 — completes in microseconds.
/// **Not** for production use.
const FAST: Argon2Params = Argon2Params {
m_cost: 8,
t_cost: 1,
p_cost: 1,
};
#[test]
fn deterministic() {
let mut out1 = [0u8; 32];
let mut out2 = [0u8; 32];
argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
argon2id(b"password", b"saltsalt", FAST, &mut out2).unwrap();
assert_eq!(out1, out2);
}
#[test]
fn sensitive_to_password() {
let mut out1 = [0u8; 32];
let mut out2 = [0u8; 32];
argon2id(b"password1", b"saltsalt", FAST, &mut out1).unwrap();
argon2id(b"password2", b"saltsalt", FAST, &mut out2).unwrap();
assert_ne!(out1, out2);
}
#[test]
fn sensitive_to_salt() {
let mut out1 = [0u8; 32];
let mut out2 = [0u8; 32];
argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
argon2id(b"password", b"saltXXXX", FAST, &mut out2).unwrap();
assert_ne!(out1, out2);
}
#[test]
fn sensitive_to_t_cost() {
let params2 = Argon2Params {
m_cost: 8,
t_cost: 2,
p_cost: 1,
};
let mut out1 = [0u8; 32];
let mut out2 = [0u8; 32];
argon2id(b"password", b"saltsalt", FAST, &mut out1).unwrap();
argon2id(b"password", b"saltsalt", params2, &mut out2).unwrap();
assert_ne!(out1, out2);
}
#[test]
fn output_length_affects_result() {
// Argon2 output is not extendable: different output lengths produce different
// initial bytes (unlike HKDF). This tests that the output-length parameter
// is correctly threaded through to the argon2 crate.
let mut out32 = [0u8; 32];
let mut out64 = [0u8; 64];
argon2id(b"password", b"saltsalt", FAST, &mut out32).unwrap();
argon2id(b"password", b"saltsalt", FAST, &mut out64).unwrap();
assert_ne!(out32, out64[..32]);
}
#[test]
fn output_16_bytes() {
let mut out = [0u8; 16];
argon2id(b"password", b"saltsalt", FAST, &mut out).unwrap();
assert!(out.iter().any(|&b| b != 0));
}
#[test]
fn empty_password_accepted() {
let mut out = [0u8; 32];
argon2id(b"", b"saltsalt", FAST, &mut out).unwrap();
assert!(out.iter().any(|&b| b != 0));
}
#[test]
fn salt_too_short_rejected() {
let mut out = [0u8; 32];
let err = argon2id(b"password", b"short", FAST, &mut out).unwrap_err();
assert!(
matches!(
err,
Error::InvalidLength {
expected: 8,
got: 5
}
),
"expected InvalidLength(8, 5), got {err:?}"
);
}
#[test]
fn empty_salt_rejected() {
let mut out = [0u8; 32];
let err = argon2id(b"password", b"", FAST, &mut out).unwrap_err();
assert!(
matches!(
err,
Error::InvalidLength {
expected: 8,
got: 0
}
),
"expected InvalidLength(8, 0), got {err:?}"
);
}
#[test]
fn empty_output_rejected() {
let mut out: [u8; 0] = [];
let err = argon2id(b"password", b"saltsalt", FAST, &mut out).unwrap_err();
assert!(
matches!(
err,
Error::InvalidLength {
expected: 1,
got: 0
}
),
"expected InvalidLength(1, 0), got {err:?}"
);
}
#[test]
fn zero_m_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 0,
t_cost: 1,
p_cost: 1,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn zero_t_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 8,
t_cost: 0,
p_cost: 1,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn zero_p_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 8,
t_cost: 1,
p_cost: 0,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn excessive_m_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 4_194_305,
t_cost: 1,
p_cost: 1,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn excessive_t_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 8,
t_cost: 257,
p_cost: 1,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn excessive_p_cost_returns_invalid_data() {
let bad = Argon2Params {
m_cost: 8,
t_cost: 1,
p_cost: 257,
};
let mut out = [0u8; 32];
assert!(matches!(
argon2id(b"pw", b"saltsalt", bad, &mut out),
Err(Error::InvalidData)
));
}
#[test]
fn output_too_large_rejected() {
let mut out = vec![0u8; 4097];
let err = argon2id(b"pw", b"saltsalt", FAST, &mut out).unwrap_err();
assert!(matches!(
err,
Error::InvalidLength {
expected: 4096,
got: 4097
}
));
}
#[test]
fn boundary_t_cost_accepted() {
let params = Argon2Params {
m_cost: 8,
t_cost: 256,
p_cost: 1,
};
let mut out = [0u8; 32];
assert!(argon2id(b"pw", b"saltsalt", params, &mut out).is_ok());
}
#[test]
fn boundary_p_cost_accepted() {
// m_cost must be >= 8 * p_cost per the argon2 spec, so m_cost = 2048
// is the minimum that satisfies the constraint for p_cost = 256.
let params = Argon2Params {
m_cost: 2048,
t_cost: 1,
p_cost: 256,
};
let mut out = [0u8; 32];
assert!(argon2id(b"pw", b"saltsalt", params, &mut out).is_ok());
}
#[test]
fn boundary_output_size_accepted() {
let mut out = vec![0u8; 4096];
assert!(argon2id(b"pw", b"saltsalt", FAST, &mut out).is_ok());
}
}

View file

@ -0,0 +1,195 @@
//! Ed25519 signatures (RFC 8032, FIPS 186-5).
//!
//! Native Ed25519 signing/verification via `ed25519-dalek`. Unlike XEdDSA,
//! this uses a dedicated Ed25519 keypair — the X25519 key inside X-Wing is
//! not involved in signing.
use crate::error::{Error, Result};
use zeroize::Zeroize;
/// Ed25519 signature size: 64 bytes.
pub const SIGNATURE_SIZE: usize = 64;
/// Ed25519 public key size: 32 bytes.
pub const PUBLIC_KEY_SIZE: usize = 32;
/// Ed25519 secret key size: 32 bytes.
pub const SECRET_KEY_SIZE: usize = 32;
/// Generate an Ed25519 keypair from the OS CSPRNG.
///
/// Generates 32 random bytes via `getrandom` and constructs the keypair
/// deterministically from that seed. The `SigningKey` implements
/// `ZeroizeOnDrop` — no manual cleanup needed.
#[must_use = "dropping the keypair loses secret key material without zeroization"]
pub fn keygen() -> (ed25519_dalek::VerifyingKey, ed25519_dalek::SigningKey) {
// Generate 32 random bytes and use them as the Ed25519 secret key seed.
// This matches ed25519-dalek's SigningKey::generate() but uses our
// getrandom-based CSPRNG instead of requiring a rand_core::CryptoRngCore.
let mut seed: [u8; 32] = super::random::random_array();
let sk = ed25519_dalek::SigningKey::from_bytes(&seed);
let vk = sk.verifying_key();
// seed is [u8; 32] (Copy) — SigningKey::from_bytes copies it into the
// SigningKey (which is ZeroizeOnDrop). Zeroize the original stack slot.
seed.zeroize();
(vk, sk)
}
/// Sign a message with an Ed25519 secret key (RFC 8032).
///
/// Deterministic: the same (sk, message) always produces the same signature
/// (no random nonce, unlike XEdDSA). The nonce is derived from SHA-512 of the
/// secret key's prefix and the message, per RFC 8032 §5.1.6.
#[must_use = "signature must not be discarded"]
pub fn sign(sk: &ed25519_dalek::SigningKey, message: &[u8]) -> [u8; 64] {
use ed25519_dalek::Signer;
sk.sign(message).to_bytes()
}
/// Verify an Ed25519 signature (RFC 8032, strict mode).
///
/// Uses `verify_strict` which rejects non-canonical signatures and small-order
/// public keys, preventing malleability attacks.
pub fn verify(vk: &ed25519_dalek::VerifyingKey, message: &[u8], sig: &[u8; 64]) -> Result<()> {
let signature = ed25519_dalek::Signature::from_bytes(sig);
vk.verify_strict(message, &signature)
.map_err(|_| Error::VerificationFailed)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use proptest::prelude::*;
#[test]
fn sign_verify_round_trip() {
let (vk, sk) = keygen();
let msg = b"hello ed25519";
let sig = sign(&sk, msg);
assert!(verify(&vk, msg, &sig).is_ok());
}
#[test]
fn sign_verify_different_messages() {
let (vk, sk) = keygen();
let sig = sign(&sk, b"message one");
assert!(matches!(
verify(&vk, b"message two", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn sign_verify_different_keys() {
let (_vk1, sk1) = keygen();
let (vk2, _sk2) = keygen();
let sig = sign(&sk1, b"test message");
assert!(matches!(
verify(&vk2, b"test message", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn signature_is_64_bytes() {
let (_vk, sk) = keygen();
let sig = sign(&sk, b"size check");
assert!(sig.iter().any(|&b| b != 0));
}
#[test]
fn sign_is_deterministic() {
let (_vk, sk) = keygen();
let msg = b"same message both times";
let sig1 = sign(&sk, msg);
let sig2 = sign(&sk, msg);
// Ed25519 is deterministic (RFC 8032) — unlike XEdDSA which uses random Z.
assert_eq!(sig1, sig2);
}
#[test]
fn verify_empty_message() {
let (vk, sk) = keygen();
let msg: &[u8] = &[];
let sig = sign(&sk, msg);
assert!(verify(&vk, msg, &sig).is_ok());
}
#[test]
fn verify_large_message() {
let (vk, sk) = keygen();
let msg = vec![0xABu8; 65536]; // 64 KB
let sig = sign(&sk, &msg);
assert!(verify(&vk, &msg, &sig).is_ok());
}
#[test]
fn verify_rejects_non_canonical_s() {
let (vk, sk) = keygen();
let msg = b"non-canonical S test";
let mut sig = sign(&sk, msg);
assert!(verify(&vk, msg, &sig).is_ok());
// Replace S (bytes 32..64) with L, the Ed25519 curve order.
let l_bytes: [u8; 32] = [
0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9,
0xde, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x10,
];
sig[32..64].copy_from_slice(&l_bytes);
assert!(matches!(
verify(&vk, msg, &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn regression_vector_rfc8032() {
// RFC 8032 §7.1 Test Vector 1 (empty message).
use hex_literal::hex;
let sk_bytes = hex!("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60");
let pk_bytes = hex!("d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a");
let expected_sig = hex!(
"e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e06522490155"
"5fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b"
);
let sk = ed25519_dalek::SigningKey::from_bytes(&sk_bytes);
let vk = sk.verifying_key();
// Verify the derived public key matches the RFC 8032 test vector.
assert_eq!(vk.as_bytes(), &pk_bytes);
let sig = sign(&sk, b"");
assert_eq!(sig, expected_sig);
assert!(verify(&vk, b"", &sig).is_ok());
}
proptest! {
#![proptest_config(proptest::prelude::ProptestConfig::with_cases(1000))]
#[test]
#[allow(clippy::cast_possible_truncation)]
fn proptest_round_trip(
sk_bytes in prop::array::uniform32(any::<u8>()),
msg_len in 0..4096usize,
flip_byte in 0..64usize,
) {
let sk = ed25519_dalek::SigningKey::from_bytes(&sk_bytes);
let vk = sk.verifying_key();
let msg: Vec<u8> = (0..msg_len).map(|i| (i % 256) as u8).collect();
let sig = sign(&sk, &msg);
prop_assert!(verify(&vk, &msg, &sig).is_ok());
// Forgery rejection: mutated signature must fail verification.
let mut bad_sig = sig;
bad_sig[flip_byte] ^= 0x01;
prop_assert!(verify(&vk, &msg, &bad_sig).is_err());
// Forgery rejection: mutated message must fail verification.
if !msg.is_empty() {
let mut bad_msg = msg.clone();
bad_msg[0] ^= 0x01;
prop_assert!(verify(&vk, &bad_msg, &sig).is_err());
}
}
}
}

View file

@ -0,0 +1,161 @@
//! HKDF-SHA3-256 (RFC 5869 construction with SHA3-256).
use crate::error::{Error, Result};
use hkdf::Hkdf;
use sha3::Sha3_256;
/// Perform HKDF-SHA3-256 extract-and-expand.
///
/// Derives `out.len()` bytes of keying material.
/// Maximum output length: 255 * 32 = 8160 bytes.
///
/// # Errors
///
/// Returns `InvalidLength` if `out` is empty or exceeds 8160 bytes.
///
/// # Security
///
/// The caller is responsible for zeroizing the `out` buffer when the derived
/// key material is no longer needed. The `hkdf` crate's internal PRK is
/// **not** zeroized on drop (`hkdf 0.13.0-rc.5` has no `zeroize` feature).
/// Mitigation: `Hkdf` is a short-lived local — PRK lifetime is bounded to
/// this function scope, and `panic = "abort"` prevents unwinding.
pub fn hkdf_sha3_256(salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<()> {
if out.is_empty() {
return Err(Error::InvalidLength {
expected: 1,
got: 0,
});
}
// HKDF-Expand maximum: 255 × HashLen (SHA3-256 = 32 bytes) = 8160 bytes.
if out.len() > 255 * 32 {
return Err(Error::InvalidLength {
expected: 255 * 32,
got: out.len(),
});
}
let hk = Hkdf::<Sha3_256>::new(Some(salt), ikm);
// Length already validated above — expand cannot fail.
hk.expand(info, out)
.expect("HKDF output length already validated");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use hex_literal::hex;
// Test vectors: RFC 5869 input patterns computed with SHA3-256 via Python 3
// hashlib HKDF implementation (provenance: manual HMAC-SHA3-256-based
// extract+expand, verified against this Rust `hkdf`+`sha3` crate output).
#[test]
fn case1() {
let ikm = [0x0b; 22];
let salt = hex!("000102030405060708090a0b0c");
let info = hex!("f0f1f2f3f4f5f6f7f8f9");
let expected = hex!(
"0c5160501d65021deaf2c14f5abce04c"
"5bd2635abceeba61c2edb6e8ed726749"
"00557728f2c9f2c4c179"
);
let mut out = [0u8; 42];
hkdf_sha3_256(&salt, &ikm, &info, &mut out).unwrap();
assert_eq!(out, expected);
}
#[test]
fn case2() {
let ikm: Vec<u8> = (0x00..=0x4f).collect();
let salt: Vec<u8> = (0x60..=0xaf).collect();
let info: Vec<u8> = (0xb0..=0xff).collect();
let expected = hex!(
"3dc251e66c75da6560405ec5ac10e17d"
"851eedfbfdc13feafbec16964c25d021"
"bd971465a3e9c615f27769019e3f0407"
"d84986fb0ba24e729c99834624baa21c"
"b623dc0098f430d52e18bbdf694df4ed"
"d8b2"
);
let mut out = [0u8; 82];
hkdf_sha3_256(&salt, &ikm, &info, &mut out).unwrap();
assert_eq!(out, expected);
}
#[test]
fn case3() {
let ikm = [0x0b; 22];
let salt = b"";
let info = b"";
let expected = hex!(
"bc1342cdd75c05e8b0c3ae609ce44106"
"84d197232875073499b30cdfe2de2853"
"c1c1bed63d725e885e78"
);
let mut out = [0u8; 42];
hkdf_sha3_256(salt, &ikm, info, &mut out).unwrap();
assert_eq!(out, expected);
}
#[test]
fn output_32_bytes() {
let mut out = [0u8; 32];
hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).unwrap();
assert!(out.iter().any(|&b| b != 0));
}
#[test]
fn output_64_bytes() {
let mut out = [0u8; 64];
hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).unwrap();
assert!(out.iter().any(|&b| b != 0));
}
#[test]
fn output_1_byte() {
let mut out = [0u8; 1];
hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).unwrap();
assert_ne!(out[0], 0u8);
}
#[test]
fn max_output() {
let mut out = vec![0u8; 255 * 32];
hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).unwrap();
assert!(out.iter().any(|&b| b != 0));
}
#[test]
fn max_output_kat() {
// KAT for T(255) boundary — verifies the final HMAC iteration of
// HKDF-Expand produces correct output. Reference: Python 3.12+
// hmac + hashlib (sha3_256).
let salt = [0u8; 32];
let ikm = b"test input keying material";
let info = b"test info";
let mut out = vec![0u8; 8160];
hkdf_sha3_256(&salt, ikm, info, &mut out).unwrap();
assert_eq!(
&out[..32],
&hex_literal::hex!("fe631a871e206c76b2328b44340b951ec80635132cef2b633d9fffbed3b03af6")
);
assert_eq!(
&out[8128..],
&hex_literal::hex!("8e358e5b1b40fcdeb1a51bda1f65c3d76908111a55738dd5d5e6991541aa8b4c")
);
}
#[test]
fn empty_output_returns_error() {
let mut out: [u8; 0] = [];
assert!(hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).is_err());
}
#[test]
fn oversized_output_returns_error() {
let mut out = vec![0u8; 255 * 32 + 1];
assert!(hkdf_sha3_256(b"salt", b"ikm", b"info", &mut out).is_err());
}
}

View file

@ -0,0 +1,135 @@
//! HMAC-SHA3-256 via RustCrypto (`hmac`/`sha3` crates).
//!
//! MAC computation and constant-time comparison are pure Rust,
//! using the `hmac` and `sha3` crates.
use hmac::{Hmac, KeyInit, Mac};
use sha3::Sha3_256;
use subtle::ConstantTimeEq;
type HmacSha3_256 = Hmac<Sha3_256>;
/// Compute HMAC-SHA3-256(key, data).
///
/// Key can be any length (hashed to block size per RFC 2104 if needed).
/// The `hmac` crate's `zeroize` feature is enabled — the internal HMAC key
/// schedule (ipad/opad) is zeroized on drop via `ZeroizeOnDrop`.
///
/// # Security
///
/// The returned `[u8; 32]` is a plain array. If the caller uses it as
/// secret key material (e.g. chain key derivation), the caller is
/// responsible for zeroizing the value when it is no longer needed.
#[must_use]
pub fn hmac_sha3_256(key: &[u8], data: &[u8]) -> [u8; 32] {
let mut mac = HmacSha3_256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().into()
}
/// Compare two 32-byte HMAC values in constant time.
///
/// # Security
///
/// Uses `subtle::ConstantTimeEq` — execution time does not depend on which
/// bytes differ, preventing timing side-channels on HMAC token comparison.
#[must_use]
pub fn hmac_sha3_256_verify_raw(a: &[u8; 32], b: &[u8; 32]) -> bool {
a.ct_eq(b).into()
}
#[cfg(test)]
mod tests {
use super::*;
use hex_literal::hex;
// Test vectors: RFC 4231 input patterns computed with SHA3-256 via Python 3
// hashlib (provenance: `hmac.new(key, msg, 'sha3_256').hexdigest()`).
#[test]
fn case1() {
let key = [0x0b; 20];
let data = b"Hi There";
let expected = hex!("ba85192310dffa96e2a3a40e69774351140bb7185e1202cdcc917589f95e16bb");
assert_eq!(hmac_sha3_256(&key, data), expected);
}
#[test]
fn case2() {
let key = b"Jefe";
let data = b"what do ya want for nothing?";
let expected = hex!("c7d4072e788877ae3596bbb0da73b887c9171f93095b294ae857fbe2645e1ba5");
assert_eq!(hmac_sha3_256(key, data), expected);
}
#[test]
fn case3() {
let key = [0xaa; 20];
let data = [0xdd; 50];
let expected = hex!("84ec79124a27107865cedd8bd82da9965e5ed8c37b0ac98005a7f39ed58a4207");
assert_eq!(hmac_sha3_256(&key, &data), expected);
}
#[test]
fn case4() {
let key = hex!("0102030405060708090a0b0c0d0e0f10111213141516171819");
let data = [0xcd; 50];
let expected = hex!("57366a45e2305321a4bc5aa5fe2ef8a921f6af8273d7fe7be6cfedb3f0aea6d7");
assert_eq!(hmac_sha3_256(&key, &data), expected);
}
#[test]
fn case6() {
let key = [0xaa; 131];
let data = b"Test Using Larger Than Block-Size Key - Hash Key First";
let expected = hex!("ed73a374b96c005235f948032f09674a58c0ce555cfc1f223b02356560312c3b");
assert_eq!(hmac_sha3_256(&key, data), expected);
}
#[test]
fn case7() {
let key = [0xaa; 131];
let data = b"This is a test using a larger than block-size key and a larger than block-size data. The key needs to be hashed before being used by the HMAC algorithm.";
let expected = hex!("65c5b06d4c3de32a7aef8763261e49adb6e2293ec8e7c61e8de61701fc63e123");
assert_eq!(hmac_sha3_256(&key, data), expected);
}
#[test]
fn verify_raw_correct() {
let mac = hmac_sha3_256(b"key", b"data");
assert!(hmac_sha3_256_verify_raw(&mac, &mac));
}
#[test]
fn verify_raw_wrong() {
let mac_a = hmac_sha3_256(b"key", b"data");
let mac_b = hmac_sha3_256(b"key", b"other");
assert!(!hmac_sha3_256_verify_raw(&mac_a, &mac_b));
}
#[test]
fn verify_raw_single_bit_diff() {
let mac = hmac_sha3_256(b"key", b"data");
let mut flipped = mac;
flipped[0] ^= 0x01;
assert!(!hmac_sha3_256_verify_raw(&mac, &flipped));
}
#[test]
fn empty_key() {
let mac = hmac_sha3_256(b"", b"data");
assert!(mac.iter().any(|&b| b != 0));
}
#[test]
fn empty_data() {
let mac = hmac_sha3_256(b"key", b"");
assert!(mac.iter().any(|&b| b != 0));
}
#[test]
fn empty_key_and_empty_data() {
let mac = hmac_sha3_256(b"", b"");
assert!(mac.iter().any(|&b| b != 0));
}
}

View file

@ -0,0 +1,462 @@
//! ML-DSA-65 (FIPS 204) digital signatures via the `ml-dsa` crate.
//!
//! Uses the seed-based API with entropy from `getrandom`. Key generation
//! is cryptographically equivalent to the RNG-based API — `key_gen(rng)`
//! internally just draws 32 random bytes then calls `from_seed` (FIPS 204 §6.1).
//!
//! Secret keys are stored as the 32-byte seed (FIPS 204 §6.1 `ξ`), not the
//! ~4032-byte expanded form. The signing key is deterministically re-expanded
//! from the seed on each `sign()` call. This minimises key material in memory
//! and on disk, and matches the canonical representation in the standard.
//!
//! ## Hedged Signing via `sign_internal`
//!
//! Signing uses the `sign_internal` API with 32 bytes of fresh `getrandom`
//! entropy as the `rnd` parameter (hedged mode). This provides fault-injection
//! resistance: with deterministic signing, a single induced fault during signing
//! (voltage glitch, Rowhammer) can extract the secret key via differential
//! analysis. Hedged signing mixes fresh randomness into the nonce derivation,
//! making each signing run independent even on the same message.
//!
//! `sign_internal` / `verify_internal` skip the FIPS 204 domain separator and
//! context string, making soliton ML-DSA signatures incompatible with
//! standalone FIPS 204 verifiers. This is intentional — lo signatures are
//! always verified by soliton (or a reimplementation built from Specification.md),
//! never by a generic FIPS 204 implementation. The same approach is used for
//! the X-Wing combiner (§4).
use crate::error::{Error, Result};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
/// ML-DSA-65 public key (1952 bytes).
#[derive(Clone, PartialEq, Eq)]
pub struct PublicKey(pub(crate) Vec<u8>);
/// ML-DSA-65 secret key (32-byte seed, FIPS 204 §6.1 `ξ`).
///
/// The seed is sufficient to deterministically reconstruct the full signing key
/// via `SigningKey::from_seed`. Storing only the seed reduces key material
/// exposure compared to the ~4032-byte expanded form.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SecretKey(pub(crate) Vec<u8>);
/// ML-DSA-65 signature (3309 bytes, FIPS 204).
#[derive(Clone, PartialEq, Eq)]
pub struct Signature(pub(crate) Vec<u8>);
/// ML-DSA-65 public key length (bytes).
const PK_LEN: usize = 1952;
/// ML-DSA-65 secret key length (bytes, seed form per FIPS 204 §6.1).
const SK_LEN: usize = 32;
/// ML-DSA-65 signature length (bytes).
const SIG_LEN: usize = 3309;
impl PublicKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != PK_LEN {
return Err(Error::InvalidLength {
expected: PK_LEN,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Construct from raw bytes without size validation (internal trusted use).
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
PK_LEN,
"mldsa::PublicKey::from_bytes_unchecked: wrong size"
);
Self(bytes)
}
}
impl SecretKey {
/// Return the raw byte representation.
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
// Wrap in Zeroizing so the raw Vec is zeroized on the error path
// (SecretKey derives ZeroizeOnDrop, but that only fires on success).
let mut bytes = Zeroizing::new(bytes);
if bytes.len() != SK_LEN {
return Err(Error::InvalidLength {
expected: SK_LEN,
got: bytes.len(),
});
}
Ok(Self(std::mem::take(&mut *bytes)))
}
}
impl Signature {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != SIG_LEN {
return Err(Error::InvalidLength {
expected: SIG_LEN,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Construct from raw bytes without size validation (internal trusted use).
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
SIG_LEN,
"mldsa::Signature::from_bytes_unchecked: wrong size"
);
Self(bytes)
}
}
/// Return the expected public key length in bytes.
pub const fn pk_len() -> usize {
PK_LEN
}
/// Return the expected secret key length in bytes (32-byte seed).
pub const fn sk_len() -> usize {
SK_LEN
}
/// Return the expected signature length in bytes.
pub const fn sig_len() -> usize {
SIG_LEN
}
/// Generate an ML-DSA-65 keypair.
///
/// Uses `getrandom` as the entropy source, passed to the deterministic
/// `from_seed` API. Equivalent to ML-DSA.KeyGen() from FIPS 204 §6.1.
///
/// The secret key is the 32-byte seed `ξ`, not the expanded form.
///
/// # Security
///
/// The 32-byte seed and its `B32` copy are zeroized immediately after key
/// generation. The extracted seed is wrapped in `Zeroizing` before being
/// moved into `SecretKey` (which derives `ZeroizeOnDrop`).
pub fn keygen() -> Result<(PublicKey, SecretKey)> {
use ml_dsa::{B32, KeyGen, MlDsa65};
// Generate 32-byte seed from OS CSPRNG.
let mut seed_bytes = [0u8; 32];
super::random::random_bytes(&mut seed_bytes);
let mut seed: B32 = seed_bytes.into();
// [u8; 32] is Copy — B32::from() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
seed_bytes.zeroize();
let kp = MlDsa65::from_seed(&seed);
seed.zeroize();
let pk_bytes = kp.verifying_key().encode().to_vec();
// B32 (hybrid_array::Array<u8, U32>) implements Zeroize but not ZeroizeOnDrop.
// Bind the return value to explicitly zeroize the seed copy.
let mut seed_out = kp.to_seed();
let mut sk_bytes = zeroize::Zeroizing::new(seed_out.to_vec());
seed_out.zeroize();
// Runtime asserts (not debug_assert) because these sizes come from a foreign
// crate's type-level constants — a crate update could silently change them.
assert_eq!(
pk_bytes.len(),
PK_LEN,
"ML-DSA-65 VK size mismatch: got {}, expected {PK_LEN}",
pk_bytes.len()
);
assert_eq!(
sk_bytes.len(),
SK_LEN,
"ML-DSA-65 seed size mismatch: got {}, expected {SK_LEN}",
sk_bytes.len()
);
Ok((
PublicKey(pk_bytes),
SecretKey(std::mem::take(&mut *sk_bytes)),
))
}
/// Sign a message with ML-DSA-65 (hedged mode).
///
/// Re-expands the signing key from the stored 32-byte seed, generates 32
/// bytes of fresh entropy from `getrandom`, and signs via `sign_internal`
/// with the entropy as the `rnd` parameter. See module doc for rationale.
///
/// # Security
///
/// The expanded `SigningKey` is zeroized on drop (`ml-dsa` feature `zeroize`).
/// The 32-byte seed is borrowed from `SecretKey`, not copied. The `rnd`
/// buffer is zeroized after use.
pub fn sign(sk: &SecretKey, message: &[u8]) -> Result<Signature> {
use ml_dsa::{B32, MlDsa65, Seed, SigningKey};
if sk.0.len() != SK_LEN {
return Err(Error::InvalidLength {
expected: SK_LEN,
got: sk.0.len(),
});
}
// ml-dsa requires its own typed key — raw &[u8] cannot be passed directly.
// ZeroizeOnDrop guaranteed by ml-dsa features = ["zeroize"].
let seed: &Seed = sk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
let signing_key = SigningKey::<MlDsa65>::from_seed(seed);
// Hedged signing: 32 bytes of fresh getrandom entropy mixed into the
// nonce derivation (FIPS 204 Algorithm 2, `rnd` parameter). Provides
// fault-injection resistance — see module doc.
let mut rnd_bytes = [0u8; 32];
super::random::random_bytes(&mut rnd_bytes);
let mut rnd: B32 = rnd_bytes.into();
// [u8; 32] is Copy — .into() received a bitwise copy.
rnd_bytes.zeroize();
// sign_internal skips the FIPS 204 domain separator / context string.
// Paired with verify_internal in verify() — both use MuBuilder::internal,
// so signatures are self-consistent within soliton.
let sig = signing_key.sign_internal(&[message], &rnd);
// B32 (hybrid_array::Array<u8, U32>) implements Zeroize but not ZeroizeOnDrop.
// Explicitly zeroize the hedged randomness after signing — same pattern as
// seed zeroization in keygen().
rnd.zeroize();
let sig_bytes = sig.encode().to_vec();
// Runtime assert (not debug_assert) because SIG_LEN comes from a foreign
// crate's type-level constant — a crate update could silently change it.
assert_eq!(
sig_bytes.len(),
SIG_LEN,
"ML-DSA-65 sig size mismatch: got {}, expected {SIG_LEN}",
sig_bytes.len()
);
Ok(Signature(sig_bytes))
}
/// Verify an ML-DSA-65 signature.
///
/// Returns `Ok(())` on valid signature, `Err(VerificationFailed)` otherwise.
pub fn verify(pk: &PublicKey, message: &[u8], signature: &Signature) -> Result<()> {
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, VerifyingKey};
if pk.0.len() != PK_LEN {
return Err(Error::InvalidLength {
expected: PK_LEN,
got: pk.0.len(),
});
}
if signature.0.len() != SIG_LEN {
return Err(Error::InvalidLength {
expected: SIG_LEN,
got: signature.0.len(),
});
}
// ml-dsa requires its own typed key — raw &[u8] cannot be passed directly.
let vk_enc: &EncodedVerifyingKey<MlDsa65> =
pk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
// Infallible: decode() from a correctly-sized EncodedVerifyingKey always
// succeeds — the encoding is a fixed-length byte array, not a parsed format.
let vk = VerifyingKey::<MlDsa65>::decode(vk_enc);
// ml-dsa requires its own typed signature — raw &[u8] cannot be passed directly.
let sig_enc: &EncodedSignature<MlDsa65> = signature
.0
.as_slice()
.try_into()
.map_err(|_| Error::Internal)?;
// VerificationFailed (not InvalidData) — avoids leaking whether the
// signature was malformed vs mathematically incorrect to the caller.
let sig = ml_dsa::Signature::<MlDsa65>::decode(sig_enc).ok_or(Error::VerificationFailed)?;
// verify_internal skips the FIPS 204 domain separator / context string.
// Paired with sign_internal in sign() — both use MuBuilder::internal.
// sign_internal takes &[&[u8]] (slice-of-slices); verify_internal takes
// &[u8] (flat slice). The asymmetry is safe: SHA3's streaming absorb
// produces identical hashes regardless of chunking boundaries.
if vk.verify_internal(message, &sig) {
Ok(())
} else {
Err(Error::VerificationFailed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[test]
fn keygen_sizes() {
let (pk, sk) = keygen().unwrap();
assert_eq!(pk.as_bytes().len(), 1952);
assert_eq!(sk.as_bytes().len(), 32);
}
#[test]
fn sign_verify_round_trip() {
let (pk, sk) = keygen().unwrap();
let sig = sign(&sk, b"hello mldsa").unwrap();
assert!(verify(&pk, b"hello mldsa", &sig).is_ok());
}
#[test]
fn sign_size() {
let (_, sk) = keygen().unwrap();
let sig = sign(&sk, b"test").unwrap();
assert_eq!(sig.as_bytes().len(), 3309);
}
#[test]
fn sign_verify_empty_message() {
let (pk, sk) = keygen().unwrap();
let sig = sign(&sk, b"").unwrap();
assert!(verify(&pk, b"", &sig).is_ok());
}
#[test]
fn verify_wrong_message() {
let (pk, sk) = keygen().unwrap();
let sig = sign(&sk, b"message one").unwrap();
assert!(matches!(
verify(&pk, b"message two", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn verify_wrong_key() {
let (_, sk) = keygen().unwrap();
let (pk2, _) = keygen().unwrap();
let sig = sign(&sk, b"test").unwrap();
assert!(matches!(
verify(&pk2, b"test", &sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn verify_tampered_signature() {
let (pk, sk) = keygen().unwrap();
let sig = sign(&sk, b"test").unwrap();
let mut bad = sig.as_bytes().to_vec();
bad[0] ^= 0xFF;
let bad_sig = Signature::from_bytes(bad).unwrap();
assert!(matches!(
verify(&pk, b"test", &bad_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn verify_all_zeros_signature() {
let (pk, _) = keygen().unwrap();
let zeros_sig = Signature::from_bytes(vec![0u8; 3309]).unwrap();
assert!(matches!(
verify(&pk, b"test", &zeros_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn pk_from_bytes_wrong_size() {
assert!(matches!(
PublicKey::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 1952,
got: 100
})
));
}
#[test]
fn sig_from_bytes_wrong_size() {
assert!(matches!(
Signature::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 3309,
got: 100
})
));
}
#[test]
fn sk_from_bytes_wrong_size() {
assert!(matches!(
SecretKey::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 32,
got: 100
})
));
}
#[test]
fn sign_hedged_both_verify() {
let (pk, sk) = keygen().unwrap();
let msg = b"hedged signing test";
let sig1 = sign(&sk, msg).unwrap();
let sig2 = sign(&sk, msg).unwrap();
// Hedged signing: same message produces different signatures (fresh
// rnd each time), but both must verify.
assert_ne!(
sig1.as_bytes(),
sig2.as_bytes(),
"hedged signatures on the same message must differ"
);
assert!(verify(&pk, msg, &sig1).is_ok());
assert!(verify(&pk, msg, &sig2).is_ok());
}
proptest::proptest! {
// Default case count (256) — ML-DSA keygen is computationally expensive;
// 256 iterations provide adequate coverage without excessive test time.
#[test]
fn proptest_round_trip(
msg in proptest::collection::vec(proptest::prelude::any::<u8>(), 0..1024),
flip_byte in 0..3309usize,
) {
let kg = keygen();
proptest::prop_assert!(kg.is_ok());
let (pk, sk) = kg.unwrap();
let sign_result = sign(&sk, &msg);
proptest::prop_assert!(sign_result.is_ok());
let sig = sign_result.unwrap();
proptest::prop_assert!(verify(&pk, &msg, &sig).is_ok());
// Forgery rejection: mutated signature must fail verification.
let mut bad_sig_bytes = sig.as_bytes().to_vec();
bad_sig_bytes[flip_byte] ^= 0x01;
let bad_sig = Signature::from_bytes(bad_sig_bytes).unwrap();
proptest::prop_assert!(verify(&pk, &msg, &bad_sig).is_err());
// Forgery rejection: mutated message must fail verification.
if !msg.is_empty() {
let mut bad_msg = msg.clone();
bad_msg[0] ^= 0x01;
proptest::prop_assert!(verify(&pk, &bad_msg, &sig).is_err());
}
}
}
}

View file

@ -0,0 +1,381 @@
//! ML-KEM-768 (FIPS 203) via the `ml-kem` crate.
//!
//! Uses the deterministic API (`generate_deterministic`, `encapsulate_deterministic`)
//! with entropy from `getrandom`. This is cryptographically equivalent to the
//! RNG-based API — the non-deterministic functions internally just draw random
//! bytes and pass them to the deterministic functions (see FIPS 203 §7.1-7.3).
use crate::error::{Error, Result};
use ml_kem::array::Array;
use ml_kem::{B32, EncapsulateDeterministic, EncodedSizeUser, KemCore, MlKem768};
use zeroize::{Zeroize, ZeroizeOnDrop};
/// ML-KEM-768 public key (1184 bytes).
#[derive(Clone, PartialEq, Eq)]
pub struct PublicKey(pub(crate) Vec<u8>);
/// ML-KEM-768 secret key (2400 bytes, expanded form).
///
/// # Security
///
/// Must not be resized after construction — `ZeroizeOnDrop` only zeroizes
/// the current allocation; a prior allocation freed by `Vec` resize would not
/// be zeroized.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SecretKey(pub(crate) Vec<u8>);
/// ML-KEM-768 ciphertext (1088 bytes).
#[derive(Clone, PartialEq, Eq)]
pub struct Ciphertext(pub(crate) Vec<u8>);
/// ML-KEM-768 shared secret (32 bytes, FIPS 203 §7.3).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SharedSecret(pub(crate) [u8; 32]);
impl PublicKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != PK_LEN {
return Err(Error::InvalidLength {
expected: PK_LEN,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Construct from raw bytes without size validation.
///
/// # Invariants
///
/// Caller must ensure `bytes.len()` equals `PK_LEN` (1184 bytes).
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
PK_LEN,
"mlkem::PublicKey::from_bytes_unchecked: wrong size"
);
Self(bytes)
}
}
impl SecretKey {
/// Return the raw byte representation.
pub(crate) fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes without size validation.
///
/// # Invariants
///
/// Caller must ensure `bytes.len()` equals `SK_LEN` (2400 bytes).
/// The `Vec` must not be resized after construction.
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
SK_LEN,
"mlkem::SecretKey::from_bytes_unchecked: wrong size"
);
Self(bytes)
}
}
impl Ciphertext {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != CT_LEN {
return Err(Error::InvalidLength {
expected: CT_LEN,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Construct from raw bytes without size validation.
///
/// # Invariants
///
/// Caller must ensure `bytes.len()` equals `CT_LEN` (1088 bytes).
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
CT_LEN,
"mlkem::Ciphertext::from_bytes_unchecked: wrong size"
);
Self(bytes)
}
}
impl SharedSecret {
/// Return the raw 32-byte shared secret.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
/// ML-KEM-768 public key length (bytes).
const PK_LEN: usize = 1184;
/// ML-KEM-768 secret key length (bytes, expanded form).
const SK_LEN: usize = 2400;
/// ML-KEM-768 ciphertext length (bytes).
const CT_LEN: usize = 1088;
/// Return the expected public key length in bytes.
pub const fn pk_len() -> usize {
PK_LEN
}
/// Return the expected secret key length in bytes.
pub const fn sk_len() -> usize {
SK_LEN
}
/// Return the expected ciphertext length in bytes.
pub const fn ct_len() -> usize {
CT_LEN
}
/// Generate 32 random bytes as a `B32` (hybrid-array `Array<u8, U32>`).
///
/// KL1-class stack residue: `arr` is returned by value, so Rust's calling
/// convention copies 32 bytes into the caller's stack frame. The callee-frame
/// copy persists unzeroized until the stack is overwritten. `panic = "abort"`
/// prevents unwinding from extending this lifetime.
fn random_b32() -> B32 {
let mut buf = [0u8; 32];
super::random::random_bytes(&mut buf);
let arr = Array::from(buf);
// [u8; 32] is Copy — Array::from() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
buf.zeroize();
arr
}
/// Generate an ML-KEM-768 keypair.
///
/// Uses `getrandom` as the entropy source, passed to the deterministic
/// key generation API. This is cryptographically equivalent to ML-KEM.KeyGen()
/// from FIPS 203 §7.1 — the standard algorithm draws `d` and `z` from an RNG
/// then calls KeyGen_internal(d, z).
///
/// # Security
///
/// Seeds `d` and `z` are zeroized immediately after key generation. The secret
/// key bytes are wrapped in `Zeroizing` during construction and moved into
/// `SecretKey` (which derives `ZeroizeOnDrop`).
pub fn keygen() -> Result<(PublicKey, SecretKey)> {
let mut d = random_b32();
let mut z = random_b32();
let (dk, ek) = MlKem768::generate_deterministic(&d, &z);
// d and z together determine the entire ML-KEM-768 key pair.
// B32 (hybrid-array::Array) implements Zeroize but not ZeroizeOnDrop.
d.zeroize();
z.zeroize();
let pk_bytes = ek.as_bytes().to_vec();
let mut sk_bytes = zeroize::Zeroizing::new(dk.as_bytes().to_vec());
// Runtime asserts (not debug_assert) because these sizes come from a foreign
// crate's type-level constants — a crate update could silently change them.
assert_eq!(
pk_bytes.len(),
PK_LEN,
"ML-KEM-768 EK size mismatch: got {}, expected {PK_LEN}",
pk_bytes.len()
);
assert_eq!(
sk_bytes.len(),
SK_LEN,
"ML-KEM-768 DK size mismatch: got {}, expected {SK_LEN}",
sk_bytes.len()
);
Ok((
PublicKey(pk_bytes),
SecretKey(std::mem::take(&mut *sk_bytes)),
))
}
/// Encapsulate to an ML-KEM-768 public key.
///
/// Returns (ciphertext, shared_secret).
///
/// Uses `getrandom` as the entropy source, passed to the deterministic
/// encapsulation API. Equivalent to ML-KEM.Encaps() from FIPS 203 §7.2.
///
/// # Security
///
/// The random message `m` and raw shared secret `ss` are zeroized after use.
/// The returned `SharedSecret` implements `ZeroizeOnDrop`.
pub fn encapsulate(pk: &PublicKey) -> Result<(Ciphertext, SharedSecret)> {
if pk.0.len() != PK_LEN {
return Err(Error::InvalidLength {
expected: PK_LEN,
got: pk.0.len(),
});
}
// ml-kem requires its own typed key — raw &[u8] cannot be passed directly.
let ek_enc: &ml_kem::Encoded<<MlKem768 as KemCore>::EncapsulationKey> =
pk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
let ek = <MlKem768 as KemCore>::EncapsulationKey::from_bytes(ek_enc);
let mut m = random_b32();
// Encapsulation is infallible for a well-formed EncapsulationKey — the only
// error path in the ml-kem crate is a type-level length mismatch, which
// cannot occur because `ek` was just reconstructed from validated bytes.
let (ct, mut ss) = ek
.encapsulate_deterministic(&m)
.map_err(|_| Error::Internal)?;
// m alone determines the shared secret for this encapsulation.
m.zeroize();
let ct_bytes: &[u8] = ct.as_ref();
let mut ss_bytes = [0u8; 32];
ss_bytes.copy_from_slice(ss.as_ref());
// B32 (hybrid-array::Array) implements Zeroize but not ZeroizeOnDrop.
ss.zeroize();
let result = SharedSecret(ss_bytes);
// [u8; 32] is Copy — SharedSecret() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
ss_bytes.zeroize();
Ok((Ciphertext(ct_bytes.to_vec()), result))
}
/// Decapsulate an ML-KEM-768 ciphertext.
///
/// Returns the 32-byte shared secret on success.
///
/// # Security
///
/// The raw shared secret from the `ml-kem` crate is zeroized after copying
/// into the returned `SharedSecret`. The returned type implements `ZeroizeOnDrop`.
pub fn decapsulate(sk: &SecretKey, ct: &Ciphertext) -> Result<SharedSecret> {
use kem::Decapsulate;
if sk.0.len() != SK_LEN {
return Err(Error::InvalidLength {
expected: SK_LEN,
got: sk.0.len(),
});
}
if ct.0.len() != CT_LEN {
return Err(Error::InvalidLength {
expected: CT_LEN,
got: ct.0.len(),
});
}
// ml-kem requires its own typed key — raw &[u8] cannot be passed directly.
let dk_enc: &ml_kem::Encoded<<MlKem768 as KemCore>::DecapsulationKey> =
sk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
let dk = <MlKem768 as KemCore>::DecapsulationKey::from_bytes(dk_enc);
// ml-kem requires its own typed ciphertext — raw &[u8] cannot be passed directly.
let ct_inner: ml_kem::Ciphertext<MlKem768> =
ct.0.as_slice()
.try_into()
.map_err(|_| Error::InvalidLength {
expected: CT_LEN,
got: ct.0.len(),
})?;
// The ml-kem crate implements FIPS 203 implicit rejection: invalid
// ciphertexts produce a pseudorandom shared secret via Ok(ss), never
// Err. The map_err path covers only structural/type-level errors. If a
// future crate version returns Err for ciphertext validity, this
// early-return would break constant-time implicit rejection.
let mut ss = dk
.decapsulate(&ct_inner)
.map_err(|_| Error::DecapsulationFailed)?;
let mut ss_bytes = [0u8; 32];
ss_bytes.copy_from_slice(ss.as_ref());
// B32 (hybrid-array::Array) implements Zeroize but not ZeroizeOnDrop.
ss.zeroize();
let result = SharedSecret(ss_bytes);
// [u8; 32] is Copy — SharedSecret() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
ss_bytes.zeroize();
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
#[test]
fn keygen_sizes() {
let (pk, sk) = keygen().unwrap();
assert_eq!(pk.as_bytes().len(), 1184);
assert_eq!(sk.as_bytes().len(), 2400);
}
#[test]
fn round_trip() {
let (pk, sk) = keygen().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
#[test]
fn wrong_pk_size() {
assert!(matches!(
PublicKey::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 1184,
got: 100
})
));
}
#[test]
fn wrong_ct_size() {
assert!(matches!(
Ciphertext::from_bytes(vec![0u8; 100]),
Err(Error::InvalidLength {
expected: 1088,
got: 100
})
));
}
#[test]
fn tampered_ct() {
let (pk, sk) = keygen().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let mut bad_ct_bytes = ct.as_bytes().to_vec();
bad_ct_bytes[0] ^= 0xFF;
let bad_ct = Ciphertext::from_bytes_unchecked(bad_ct_bytes);
// ML-KEM implicit rejection: decaps succeeds but produces different SS.
let ss_dec = decapsulate(&sk, &bad_ct).unwrap();
assert_ne!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
#[test]
fn round_trip_repeated() {
// ML-KEM keygen uses getrandom — proptest's RNG is irrelevant and
// seed-reproducibility is impossible. A plain loop provides identical
// coverage without misleading proptest shrinking semantics.
for _ in 0..256 {
let (pk, sk) = keygen().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
}
}

View file

@ -0,0 +1,16 @@
//! Safe Rust wrappers over cryptographic primitives.
//!
//! Each module validates inputs and zeroizes intermediates.
//! All implementations are pure Rust — no C FFI.
pub mod aead;
pub mod argon2;
pub mod ed25519;
pub mod hkdf;
pub mod hmac;
pub mod mldsa;
pub mod mlkem;
pub mod random;
pub mod sha3_256;
pub mod x25519;
pub mod xwing;

View file

@ -0,0 +1,67 @@
//! Cryptographically secure random byte generation.
/// Fill `buf` with cryptographically random bytes.
///
/// # Security
///
/// The caller is responsible for zeroizing `buf` when the random bytes are no
/// longer needed.
///
/// # Panics
///
/// Panics if the OS CSPRNG is unavailable. There is no safe fallback — failing
/// silently would produce predictable "random" bytes.
pub fn random_bytes(buf: &mut [u8]) {
// getrandom internally retries EINTR. Other errors (ENOSYS, EAGAIN on
// Linux; SecRandomCopyBytes failure on macOS) are non-recoverable.
getrandom::fill(buf).unwrap_or_else(|e| panic!("getrandom failed: {e}"));
}
/// Generate `N` cryptographically random bytes.
///
/// # Security
///
/// The returned `[u8; N]` is `Copy` — the callee's stack copy is not zeroized
/// before return. When used for key material, callers should wrap the result
/// in `Zeroizing::new(...)` or prefer [`random_bytes`] with a caller-managed
/// `Zeroizing` buffer.
#[must_use]
pub fn random_array<const N: usize>() -> [u8; N] {
let mut buf = [0u8; N];
random_bytes(&mut buf);
buf
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fills_buffer() {
let mut buf = [0u8; 64];
random_bytes(&mut buf);
assert!(buf.iter().any(|&b| b != 0));
}
#[test]
fn different_each_call() {
let mut a = [0u8; 32];
let mut b = [0u8; 32];
random_bytes(&mut a);
random_bytes(&mut b);
assert_ne!(a, b);
}
#[test]
fn array_correct_size() {
// Compile-time size anchor: `random_array::<32>()` returns `[u8; 32]`.
// Content (non-zero) is verified by `array_not_all_zeros`.
let _arr: [u8; 32] = random_array::<32>();
}
#[test]
fn array_not_all_zeros() {
let arr = random_array::<32>();
assert!(arr.iter().any(|&b| b != 0));
}
}

View file

@ -0,0 +1,82 @@
//! SHA3-256 hashing (FIPS 202).
use sha3::{Digest, Sha3_256};
/// Compute SHA3-256 hash of `data`.
#[must_use]
pub fn hash(data: &[u8]) -> [u8; 32] {
Sha3_256::digest(data).into()
}
/// Compute the hex-encoded fingerprint (SHA3-256) of a public key.
#[must_use]
pub fn fingerprint_hex(pk: &[u8]) -> String {
let digest = hash(pk);
hex_encode(&digest)
}
/// Encode bytes as lowercase hex string.
fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
// fmt::Write for String is infallible — the Result is always Ok.
let _ = write!(s, "{b:02x}");
}
s
}
#[cfg(test)]
mod tests {
use super::*;
use hex_literal::hex;
#[test]
fn empty_input() {
assert_eq!(
hash(b""),
hex!("a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a")
);
}
#[test]
fn abc() {
assert_eq!(
hash(b"abc"),
hex!("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532")
);
}
#[test]
fn long_message() {
assert_eq!(
hash(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"),
hex!("41c0dba2a9d6240849100376a8235e2c82e1b9998a999e21db32dd97496d3376")
);
}
#[test]
fn fingerprint_hex_format() {
let fp = fingerprint_hex(b"test");
assert_eq!(fp.len(), 64);
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
}
#[test]
fn fingerprint_hex_deterministic() {
let a = fingerprint_hex(b"test");
let b = fingerprint_hex(b"test");
assert_eq!(a, b);
}
#[test]
fn fingerprint_hex_matches_hash() {
let data = b"hello world";
let fp = fingerprint_hex(data);
let expected = hex::encode(hash(data));
assert_eq!(fp, expected);
}
}

View file

@ -0,0 +1,222 @@
//! X25519 key agreement (RFC 7748).
use crate::error::{Error, Result};
use curve25519_dalek::MontgomeryPoint;
use subtle::ConstantTimeEq;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// X25519 secret key (32 bytes).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SecretKey(pub(crate) [u8; 32]);
/// X25519 public key (32 bytes).
#[derive(Clone, PartialEq, Eq)]
pub struct PublicKey(pub(crate) [u8; 32]);
impl SecretKey {
/// View the raw bytes.
pub(crate) fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Construct from raw bytes.
///
/// # Security
///
/// `[u8; 32]` is `Copy` — the caller's value remains on the stack after this
/// call and must be explicitly zeroized by the caller.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl PublicKey {
/// View the raw bytes.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
/// Construct from raw bytes.
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
/// Generate a random X25519 keypair.
///
/// # Security
///
/// The random seed is zeroized after copying into the `SecretKey`. The returned
/// `SecretKey` implements `ZeroizeOnDrop`.
#[must_use = "dropping the keypair loses secret key material without zeroization"]
pub fn keygen() -> (PublicKey, SecretKey) {
let mut sk_bytes = [0u8; 32];
super::random::random_bytes(&mut sk_bytes);
let pk = MontgomeryPoint::mul_base_clamped(sk_bytes);
// [u8; 32] is Copy — SecretKey(sk_bytes) copies the bytes into the struct.
// sk_bytes.zeroize() zeroizes the stack copy; ZeroizeOnDrop on SecretKey
// handles the field copy when the caller drops the return value.
let sk = SecretKey(sk_bytes);
sk_bytes.zeroize();
(PublicKey(pk.to_bytes()), sk)
}
/// Derive X25519 public key from secret key.
#[must_use = "derived public key must not be discarded"]
pub fn public_from_secret(sk: &SecretKey) -> PublicKey {
// Known limitation: mul_base_clamped takes [u8; 32] by value, creating
// an unzeroized copy of the secret key on the stack. No fix possible
// without upstream API changes to curve25519-dalek.
PublicKey(MontgomeryPoint::mul_base_clamped(sk.0).to_bytes())
}
/// Compute X25519 Diffie-Hellman shared secret: result = sk * pk.
///
/// Returns an error if the result is a low-order point (degenerate shared secret).
/// This prevents silent degradation when given a maliciously-crafted public key.
///
/// # Security
///
/// Returns a plain `[u8; 32]` — the caller is responsible for zeroizing the
/// returned shared secret after use. Internal intermediates (`shared`,
/// `result`) are zeroized before returning.
pub fn dh(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32]> {
// Known limitation: mul_clamped takes [u8; 32] by value, creating an
// unzeroized copy of the secret key on the stack. No fix possible
// without upstream API changes to curve25519-dalek.
let mut shared = MontgomeryPoint(pk.0).mul_clamped(sk.0);
let mut result = shared.to_bytes();
shared.zeroize();
// Constant-time comparison: the result is secret material (DH output),
// so a variable-time check could leak whether the result is all-zero
// via timing.
if bool::from(result.ct_eq(&[0u8; 32])) {
result.zeroize();
return Err(Error::DecapsulationFailed);
}
// [u8; 32] is Copy — `let output = result` copies the bytes; zeroize the
// original binding to minimize stack residue of the DH secret.
let output = result;
result.zeroize();
Ok(output)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use hex_literal::hex;
#[test]
fn rfc7748_vector() {
// RFC 7748 §6.1: Alice's private key → public key (scalar × basepoint).
let sk_bytes = hex!("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
let expected_pk = hex!("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
let sk = SecretKey::from_bytes(sk_bytes);
let pk = public_from_secret(&sk);
assert_eq!(pk.as_bytes(), &expected_pk);
}
#[test]
fn rfc7748_dh_kat() {
// RFC 7748 §6.1 — full DH key agreement with both parties' keys.
let sk_a = SecretKey::from_bytes(hex!(
"77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a"
));
let sk_b = SecretKey::from_bytes(hex!(
"5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb"
));
let pk_b = public_from_secret(&sk_b);
assert_eq!(
pk_b.as_bytes(),
&hex!("de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f")
);
let ss_a = dh(&sk_a, &pk_b).unwrap();
let ss_b = dh(&sk_b, &public_from_secret(&sk_a)).unwrap();
let expected = hex!("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");
assert_eq!(ss_a, expected);
assert_eq!(ss_b, expected);
}
#[test]
fn keygen_sizes() {
let (pk, sk) = keygen();
// Content checks — `&[u8; 32]` already enforces size at compile time.
assert!(pk.as_bytes().iter().any(|&b| b != 0));
assert!(sk.as_bytes().iter().any(|&b| b != 0));
}
#[test]
fn public_from_secret_deterministic() {
let (_, sk) = keygen();
let pk1 = public_from_secret(&sk);
let pk2 = public_from_secret(&sk);
assert_eq!(pk1.as_bytes(), pk2.as_bytes());
}
#[test]
fn public_from_secret_matches_keygen() {
let (pk, sk) = keygen();
let pk2 = public_from_secret(&sk);
assert_eq!(pk.as_bytes(), pk2.as_bytes());
}
#[test]
fn dh_agreement() {
let (pk_a, sk_a) = keygen();
let (pk_b, sk_b) = keygen();
let ss_a = dh(&sk_a, &pk_b).unwrap();
let ss_b = dh(&sk_b, &pk_a).unwrap();
assert_eq!(ss_a, ss_b);
}
#[test]
fn dh_low_order_rejected() {
let (_, sk) = keygen();
let zero_pk = PublicKey::from_bytes([0u8; 32]);
assert!(matches!(dh(&sk, &zero_pk), Err(Error::DecapsulationFailed)));
}
#[test]
fn dh_small_order_points() {
let (_, sk) = keygen();
// Small-order Montgomery u-coordinates that produce all-zeros DH output.
let small_order_points: [[u8; 32]; 5] = [
[0; 32], // u = 0 (zero point)
{
let mut p = [0u8; 32];
p[0] = 1; // u = 1 (identity)
p
},
{
let mut p = [0xffu8; 32]; // u = p-1 = 2^255 - 20
p[31] = 0x7f;
p[0] = 0xec;
p
},
{
let mut p = [0xffu8; 32]; // u = p = 2^255 - 19 (≡ 0 mod p)
p[31] = 0x7f;
p[0] = 0xed;
p
},
{
let mut p = [0xffu8; 32]; // u = p+1 = 2^255 - 18 (≡ 1 mod p)
p[31] = 0x7f;
p[0] = 0xee;
p
},
];
for point in &small_order_points {
let pk = PublicKey::from_bytes(*point);
assert!(
matches!(dh(&sk, &pk), Err(Error::DecapsulationFailed)),
"small-order point {:02x?} should be rejected",
&point[..4]
);
}
}
}

View file

@ -0,0 +1,770 @@
//! X-Wing hybrid KEM (X25519 + ML-KEM-768).
//!
//! draft-connolly-cfrg-xwing-kem-09
//!
//! LO encoding (X25519-first):
//! X-Wing public key: X25519_pk (32) || ML-KEM-768_pk (1184) = 1216 bytes.
//! X-Wing secret key: X25519_sk (32) || ML-KEM-768_sk (2400) = 2432 bytes.
//! X-Wing ciphertext: X25519_ct (32) || ML-KEM-768_ct (1088) = 1120 bytes.
//! X-Wing shared secret: 32 bytes (SHA3-256 combiner).
use sha3::{Digest, Sha3_256};
use super::{mlkem, random, x25519};
use crate::constants;
use crate::error::{Error, Result};
use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
/// X-Wing public key (1216 bytes): X25519_pk || ML-KEM-768_pk.
#[derive(Clone, Eq)]
pub struct PublicKey(pub(crate) Vec<u8>);
/// Constant-time equality — prevents timing side-channels when comparing
/// ratchet public keys (e.g., in decrypt's ratchet-step detection).
impl PartialEq for PublicKey {
fn eq(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq;
if self.0.len() != other.0.len() {
return false;
}
self.0.ct_eq(&other.0).into()
}
}
/// X-Wing secret key: X25519_sk || ML-KEM-768_sk.
///
/// # Security
///
/// Must not be resized after construction — `ZeroizeOnDrop` only zeroizes
/// the current allocation; a prior allocation freed by `Vec` resize would not
/// be zeroized.
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SecretKey(pub(crate) Vec<u8>);
/// X-Wing ciphertext: X25519_ephemeral_pk || ML-KEM-768_ct.
#[derive(Clone, PartialEq, Eq)]
pub struct Ciphertext(pub(crate) Vec<u8>);
/// X-Wing shared secret (32 bytes).
#[derive(Zeroize, ZeroizeOnDrop)]
pub struct SharedSecret(pub(crate) [u8; 32]);
impl PublicKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != constants::XWING_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::XWING_PUBLIC_KEY_SIZE,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
/// Construct from raw bytes without size validation.
///
/// # Safety contract
///
/// Caller must guarantee `bytes.len() == XWING_PUBLIC_KEY_SIZE` (1216).
/// Violating this will cause panics on slice indexing in `x25519_pk` /
/// `mlkem_pk`.
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
constants::XWING_PUBLIC_KEY_SIZE,
"from_bytes_unchecked called with wrong size"
);
Self(bytes)
}
/// Extract the X25519 public key (first 32 bytes).
pub fn x25519_pk(&self) -> &[u8] {
&self.0[..32]
}
/// Extract the ML-KEM-768 public key (bytes 32..1216).
pub fn mlkem_pk(&self) -> &[u8] {
&self.0[32..]
}
}
impl SecretKey {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
///
/// Expected size: 32 (X25519) + ML-KEM-768 secret key length.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
// Wrap in Zeroizing so the raw Vec is zeroized on the error path
// (SecretKey derives ZeroizeOnDrop, but that only fires on success).
let mut bytes = Zeroizing::new(bytes);
// X25519 SK (32) + ML-KEM-768 SK (2400).
let expected = 32 + mlkem::sk_len();
if bytes.len() != expected {
return Err(Error::InvalidLength {
expected,
got: bytes.len(),
});
}
Ok(Self(std::mem::take(&mut *bytes)))
}
/// Construct from raw bytes without size validation.
///
/// # Safety contract
///
/// Caller must guarantee `bytes.len() == 32 + mlkem::sk_len()` (2432).
/// Violating this will cause panics on slice indexing in `x25519_sk` /
/// `mlkem_sk`.
pub(crate) fn from_bytes_unchecked(bytes: Vec<u8>) -> Self {
assert_eq!(
bytes.len(),
32 + mlkem::sk_len(),
"from_bytes_unchecked called with wrong size"
);
Self(bytes)
}
/// Extract the X25519 secret key (first 32 bytes).
pub(crate) fn x25519_sk(&self) -> &[u8] {
&self.0[..32]
}
/// Extract the ML-KEM-768 secret key (bytes 32..).
pub(crate) fn mlkem_sk(&self) -> &[u8] {
&self.0[32..]
}
}
impl Ciphertext {
/// Return the raw byte representation.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Construct from raw bytes with size validation.
pub fn from_bytes(bytes: Vec<u8>) -> Result<Self> {
if bytes.len() != constants::XWING_CIPHERTEXT_SIZE {
return Err(Error::InvalidLength {
expected: constants::XWING_CIPHERTEXT_SIZE,
got: bytes.len(),
});
}
Ok(Self(bytes))
}
}
impl SharedSecret {
/// Return the raw 32-byte shared secret.
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
/// X-Wing combiner label (draft-connolly-cfrg-xwing-kem-09 §5.3).
/// ASCII bytes: '\', '.', '/', '/', '^', '\' — hex: 5c 2e 2f 2f 5e 5c.
const XWING_LABEL: &[u8; 6] = b"\\.//^\\";
// Compile-time check that the string literal decodes to the exact hex values
// from draft-09 §5.3. Catches accidental escape-sequence misinterpretation.
const _: () = assert!(
XWING_LABEL[0] == 0x5c
&& XWING_LABEL[1] == 0x2e
&& XWING_LABEL[2] == 0x2f
&& XWING_LABEL[3] == 0x2f
&& XWING_LABEL[4] == 0x5e
&& XWING_LABEL[5] == 0x5c,
"XWING_LABEL must be 5c 2e 2f 2f 5e 5c per draft-09 §5.3"
);
/// SHA3-256 combiner per draft-connolly-cfrg-xwing-kem-09 §5.3.
///
/// ss = SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
///
/// # Security
///
/// The combined shared secret is secure as long as at least one of ML-KEM-768
/// or X25519 remains unbroken — SHA3-256 binds both component secrets together
/// with the ephemeral ciphertext and static public key to prevent cross-session
/// confusion.
fn combiner(ss_m: &[u8], ss_x: &[u8], ct_x: &[u8], pk_x: &[u8]) -> [u8; 32] {
let mut hasher = Sha3_256::new();
hasher.update(ss_m);
hasher.update(ss_x);
hasher.update(ct_x);
hasher.update(pk_x);
hasher.update(XWING_LABEL);
hasher.finalize().into()
}
/// Generate an X-Wing keypair.
///
/// # Security
///
/// The secret key Vec is wrapped in `Zeroizing` during construction and moved
/// into the `SecretKey` (which derives `ZeroizeOnDrop`). Component secret keys
/// (`x25519::SecretKey`, `mlkem::SecretKey`) are zeroized by their respective
/// types on drop.
#[must_use = "key material must not be discarded"]
pub fn keygen() -> Result<(PublicKey, SecretKey)> {
let (x_pk, x_sk) = x25519::keygen();
let (m_pk, m_sk) = mlkem::keygen()?;
// Validate that ML-KEM sizes match the hardcoded constants. Runtime asserts
// (not debug_assert) because these sizes come from a foreign crate's type-level
// constants — a crate update could silently change them, and the mismatch must
// be caught in every build configuration.
assert_eq!(
32 + mlkem::pk_len(),
constants::XWING_PUBLIC_KEY_SIZE,
"X-Wing public key size mismatch — update XWING_PUBLIC_KEY_SIZE"
);
assert_eq!(
32 + mlkem::sk_len(),
constants::XWING_SECRET_KEY_SIZE,
"X-Wing secret key size mismatch — update XWING_SECRET_KEY_SIZE"
);
assert_eq!(
32 + mlkem::ct_len(),
constants::XWING_CIPHERTEXT_SIZE,
"X-Wing ciphertext size mismatch — update XWING_CIPHERTEXT_SIZE"
);
// LO encoding: pk = x25519_pk || mlkem_pk
let mut pk = Vec::with_capacity(32 + m_pk.as_bytes().len());
pk.extend_from_slice(x_pk.as_bytes());
pk.extend_from_slice(m_pk.as_bytes());
// LO encoding: sk = x25519_sk || mlkem_sk
// Zeroizing wrapper ensures the Vec is zeroized on the error path — SecretKey's
// ZeroizeOnDrop only fires after successful construction.
let mut sk = Zeroizing::new(Vec::with_capacity(32 + m_sk.as_bytes().len()));
sk.extend_from_slice(x_sk.as_bytes());
sk.extend_from_slice(m_sk.as_bytes());
Ok((PublicKey(pk), SecretKey(std::mem::take(&mut *sk))))
}
/// Encapsulate to an X-Wing public key.
///
/// Returns (ciphertext, shared_secret).
///
/// # Security
///
/// All intermediate secret material (ephemeral secret key, raw DH output,
/// combiner output) is zeroized before returning. Low-order X25519 points
/// produce an all-zeros DH result rather than an error — the SHA3-256 combiner
/// is designed to be safe regardless, and ML-KEM provides full security on its
/// own (draft-09 does not reject low-order points).
#[must_use = "shared secret and ciphertext must not be discarded"]
pub fn encapsulate(pk: &PublicKey) -> Result<(Ciphertext, SharedSecret)> {
if pk.0.len() != constants::XWING_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::XWING_PUBLIC_KEY_SIZE,
got: pk.0.len(),
});
}
// Ephemeral keypair: fresh randomness per encapsulation ensures forward
// secrecy even if the recipient's long-term ML-KEM key is later compromised.
let mut ek_sk_bytes = [0u8; 32];
random::random_bytes(&mut ek_sk_bytes);
// from_bytes copies into SecretKey ([u8; 32] is Copy).
let ek_sk = x25519::SecretKey::from_bytes(ek_sk_bytes);
// Zeroize the staging copy — SecretKey's ZeroizeOnDrop covers ek_sk.0.
ek_sk_bytes.zeroize();
let ek_pk = x25519::public_from_secret(&ek_sk);
// X25519 DH: ss_x = ek_sk * recipient_x25519_pk
// Use all-zeros fallback for low-order points — the SHA3-256 combiner is
// designed to be safe regardless, and ML-KEM provides full security on its
// own. Rejecting here would let an attacker with a malicious pre-key bundle
// force session initiation to fail (draft-09 does not reject low-order points).
let recipient_x_pk = x25519::PublicKey::from_bytes({
let mut buf = [0u8; 32];
buf.copy_from_slice(pk.x25519_pk());
buf
});
let mut raw_ss_x = x25519::dh(&ek_sk, &recipient_x_pk).unwrap_or([0u8; 32]);
let ss_x = Zeroizing::new(raw_ss_x);
// [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
raw_ss_x.zeroize();
// ML-KEM provides PQ security; combined with X25519 above, the hybrid
// scheme remains secure if either primitive is broken.
let mlkem_pk = mlkem::PublicKey::from_bytes_unchecked(pk.mlkem_pk().to_vec());
let (mlkem_ct, mlkem_ss) = mlkem::encapsulate(&mlkem_pk)?;
// Ciphertext: ek_pk (ct_X) || mlkem_ct (ct_M)
let mut ct = Vec::with_capacity(32 + mlkem_ct.as_bytes().len());
ct.extend_from_slice(ek_pk.as_bytes());
ct.extend_from_slice(mlkem_ct.as_bytes());
// Combiner: SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
// ct_X = ephemeral X25519 public key (ek_pk), pk_X = recipient X25519 pk
let mut ss = combiner(
mlkem_ss.as_bytes(),
&*ss_x,
ek_pk.as_bytes(),
pk.x25519_pk(),
);
let shared = SharedSecret(ss);
// [u8; 32] is Copy — SharedSecret() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
ss.zeroize();
Ok((Ciphertext(ct), shared))
}
/// Decapsulate an X-Wing ciphertext.
///
/// Returns the 32-byte combined shared secret.
///
/// # Security
///
/// All intermediate secret material (X25519 secret key copy, raw DH output,
/// combiner output) is zeroized before returning. See `encapsulate()` for
/// low-order point handling rationale.
#[must_use = "shared secret must not be discarded"]
pub fn decapsulate(sk: &SecretKey, ct: &Ciphertext) -> Result<SharedSecret> {
// SecretKey is always constructed with validated length; debug-only sanity check.
debug_assert_eq!(
sk.0.len(),
constants::XWING_SECRET_KEY_SIZE,
"SecretKey constructed with wrong length"
);
if ct.0.len() != constants::XWING_CIPHERTEXT_SIZE {
return Err(Error::InvalidLength {
expected: constants::XWING_CIPHERTEXT_SIZE,
got: ct.0.len(),
});
}
// Extract components from ciphertext (LO encoding: ct_X || ct_M).
let ct_x = &ct.0[..32]; // ephemeral X25519 pk
let ct_m = &ct.0[32..]; // ML-KEM ciphertext
// X25519 DH: ss_x = local_x25519_sk * ct_X (ephemeral pk)
// See encapsulate() for rationale on the all-zeros fallback.
let mut x_sk_bytes = [0u8; 32];
x_sk_bytes.copy_from_slice(sk.x25519_sk());
// from_bytes copies into SecretKey ([u8; 32] is Copy).
let our_x_sk = x25519::SecretKey::from_bytes(x_sk_bytes);
// Zeroize the staging copy — SecretKey's ZeroizeOnDrop covers our_x_sk.0.
x_sk_bytes.zeroize();
let peer_ek = x25519::PublicKey::from_bytes({
let mut buf = [0u8; 32];
buf.copy_from_slice(ct_x);
buf
});
let mut raw_ss_x = x25519::dh(&our_x_sk, &peer_ek).unwrap_or([0u8; 32]);
let ss_x = Zeroizing::new(raw_ss_x);
// [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
raw_ss_x.zeroize();
// ML-KEM provides PQ security; combined with X25519 above, the hybrid
// scheme remains secure if either primitive is broken.
// .to_vec() copies the secret key slice into a new Vec; ZeroizeOnDrop on
// mlkem::SecretKey zeroizes the copy on drop.
let mlkem_sk = mlkem::SecretKey::from_bytes_unchecked(sk.mlkem_sk().to_vec());
let mlkem_ct = mlkem::Ciphertext::from_bytes_unchecked(ct_m.to_vec());
let mlkem_ss = mlkem::decapsulate(&mlkem_sk, &mlkem_ct)?;
// The combiner needs pk_X to bind the shared secret to this specific
// recipient — deriving it here avoids requiring the caller to pass it in.
let pk_x = x25519::public_from_secret(&our_x_sk);
// Combiner: SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
let mut ss = combiner(mlkem_ss.as_bytes(), &*ss_x, ct_x, pk_x.as_bytes());
let shared = SharedSecret(ss);
// [u8; 32] is Copy — SharedSecret() received a bitwise copy, so the
// original stack value must be explicitly zeroized.
ss.zeroize();
Ok(shared)
}
#[cfg(test)]
mod tests {
use super::*;
use hex_literal::hex;
use sha3::{Digest, Sha3_256};
#[test]
fn keygen_sizes() {
let (pk, sk) = keygen().unwrap();
assert_eq!(pk.as_bytes().len(), 1216);
assert_eq!(sk.as_bytes().len(), 2432);
// Content check for component accessors — `&[u8; 32]` enforces x25519_pk size at compile time.
assert!(pk.x25519_pk().iter().any(|&b| b != 0));
assert_eq!(pk.mlkem_pk().len(), 1184);
}
#[test]
fn round_trip() {
let (pk, sk) = keygen().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
#[test]
fn combiner_kat() {
let ss_m = [0x01u8; 32];
let ss_x = [0x02u8; 32];
let ct_x = [0x03u8; 32];
let pk_x = [0x04u8; 32];
let result = combiner(&ss_m, &ss_x, &ct_x, &pk_x);
let expected: [u8; 32] = Sha3_256::new()
.chain_update(ss_m)
.chain_update(ss_x)
.chain_update(ct_x)
.chain_update(pk_x)
.chain_update(XWING_LABEL)
.finalize()
.into();
assert_eq!(result, expected);
}
#[test]
fn label_hex_value() {
assert_eq!(XWING_LABEL, &[0x5c, 0x2e, 0x2f, 0x2f, 0x5e, 0x5c]);
}
#[test]
fn label_is_six_bytes() {
// Compile-time size anchor — `XWING_LABEL: &[u8; 6]` is enforced by the compiler.
// The actual byte values are verified by `label_hex_value`.
assert_eq!(XWING_LABEL.len(), 6);
}
#[test]
fn combiner_order_matters_ss() {
let a = [0x01u8; 32];
let b = [0x02u8; 32];
let c = [0x03u8; 32];
let d = [0x04u8; 32];
let out1 = combiner(&a, &b, &c, &d);
let out2 = combiner(&b, &a, &c, &d);
assert_ne!(
out1, out2,
"swapping ss_M and ss_X must produce different output"
);
}
#[test]
fn combiner_order_matters_ct_pk() {
let a = [0x01u8; 32];
let b = [0x02u8; 32];
let c = [0x03u8; 32];
let d = [0x04u8; 32];
let out1 = combiner(&a, &b, &c, &d);
let out2 = combiner(&a, &b, &d, &c);
assert_ne!(
out1, out2,
"swapping ct_X and pk_X must produce different output"
);
}
#[test]
fn combiner_label_is_last() {
let ss_m = [0x01u8; 32];
let ss_x = [0x02u8; 32];
let ct_x = [0x03u8; 32];
let pk_x = [0x04u8; 32];
let actual = combiner(&ss_m, &ss_x, &ct_x, &pk_x);
// Label at START — must NOT match combiner output.
let label_first: [u8; 32] = Sha3_256::new()
.chain_update(XWING_LABEL)
.chain_update(ss_m)
.chain_update(ss_x)
.chain_update(ct_x)
.chain_update(pk_x)
.finalize()
.into();
assert_ne!(
actual, label_first,
"label-first ordering must differ from combiner"
);
// Label at END — must match combiner output (draft-09 §5.3).
let label_last: [u8; 32] = Sha3_256::new()
.chain_update(ss_m)
.chain_update(ss_x)
.chain_update(ct_x)
.chain_update(pk_x)
.chain_update(XWING_LABEL)
.finalize()
.into();
assert_eq!(
actual, label_last,
"label-last ordering must match combiner"
);
}
#[test]
fn encapsulate_wrong_pk_size() {
assert!(matches!(
PublicKey::from_bytes(vec![0u8; 100]),
Err(crate::error::Error::InvalidLength {
expected: 1216,
got: 100
})
));
}
#[test]
fn decapsulate_wrong_ct_size() {
assert!(matches!(
Ciphertext::from_bytes(vec![0u8; 100]),
Err(crate::error::Error::InvalidLength {
expected: 1120,
got: 100
})
));
}
#[test]
fn sk_from_bytes_wrong_size() {
assert!(matches!(
SecretKey::from_bytes(vec![0u8; 100]),
Err(crate::error::Error::InvalidLength {
expected: 2432,
got: 100
})
));
}
#[test]
fn shared_secret_is_32_bytes() {
let (pk, _sk) = keygen().unwrap();
let (_ct, ss) = encapsulate(&pk).unwrap();
// Content check — `&[u8; 32]` already enforces size at compile time.
assert!(ss.as_bytes().iter().any(|&b| b != 0));
}
#[test]
fn independent_encapsulations_differ() {
// Each encapsulate() draws fresh ephemeral randomness, so two calls
// produce different (ct, ss) pairs regardless of whether the PKs
// differ. This test verifies non-degeneracy (the KEM isn't producing
// constant output), not that the recipient PK contributes to SS.
let (pk1, _sk1) = keygen().unwrap();
let (pk2, _sk2) = keygen().unwrap();
let (_ct1, ss1) = encapsulate(&pk1).unwrap();
let (_ct2, ss2) = encapsulate(&pk2).unwrap();
assert_ne!(
ss1.as_bytes(),
ss2.as_bytes(),
"independent encapsulations must produce different shared secrets"
);
}
#[test]
fn low_order_x25519_does_not_error() {
// Get a valid ML-KEM pk/sk from keygen so we can also test decapsulation.
let (pk, sk) = keygen().unwrap();
let mlkem_part = pk.mlkem_pk().to_vec();
// Small-order Montgomery u-coordinates (little-endian).
let small_order_points: Vec<[u8; 32]> = vec![
[0u8; 32], // u = 0 (zero point)
{
let mut p = [0u8; 32]; // u = 1 (2-torsion point, order 2)
p[0] = 1;
p
},
{
let mut p = [0xffu8; 32]; // u = p-1 = 2^255 - 20
p[31] = 0x7f;
p[0] = 0xec;
p
},
{
let mut p = [0xffu8; 32]; // u = p = 2^255 - 19 (≡ 0 mod p)
p[31] = 0x7f;
p[0] = 0xed;
p
},
];
let mut shared_secrets = Vec::new();
for point in &small_order_points {
// Construct pk with small-order X25519 + valid ML-KEM pk.
let mut bad_pk_bytes = point.to_vec();
bad_pk_bytes.extend_from_slice(&mlkem_part);
let bad_pk = PublicKey::from_bytes(bad_pk_bytes).unwrap();
// Encapsulate must succeed — draft-09 does not reject low-order points.
let (ct, ss_enc) = encapsulate(&bad_pk).unwrap();
// Combiner output must not be all-zero — ML-KEM component contributes entropy.
assert_ne!(
ss_enc.as_bytes(),
&[0u8; 32],
"combiner must not produce all-zero SS for small-order point {:02x?}",
&point[..4]
);
// Decapsulate must also succeed: sk's ML-KEM half matches the CT's ML-KEM
// part (same pk was used), so ML-KEM decap succeeds. pk_X in the combiner
// differs between enc (the small-order point) and dec (the X25519 public
// key derived from sk), making ss_enc != ss_dec unconditional.
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_ne!(
ss_enc.as_bytes(),
ss_dec.as_bytes(),
"low-order x25519 must cause combiner divergence between enc and dec"
);
shared_secrets.push(ss_enc);
}
// All shared secrets should differ — each encapsulate() draws fresh ephemeral X25519
// and ML-KEM randomness, not because the X25519 DH outputs differ (they are all 0).
for i in 0..shared_secrets.len() {
for j in (i + 1)..shared_secrets.len() {
assert_ne!(
shared_secrets[i].as_bytes(),
shared_secrets[j].as_bytes(),
"shared secrets for points {} and {} should differ",
i,
j
);
}
}
}
#[test]
fn round_trip_repeated() {
// X-Wing keygen uses getrandom — proptest's RNG is irrelevant and
// seed-reproducibility is impossible. A plain loop provides identical
// coverage without misleading proptest shrinking semantics.
for _ in 0..1000 {
let (pk, sk) = keygen().unwrap();
let (ct, ss_enc) = encapsulate(&pk).unwrap();
let ss_dec = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss_enc.as_bytes(), ss_dec.as_bytes());
}
}
/// Reconstruct an X-Wing `SecretKey` from a compact 32-byte seed.
///
/// Implements `expandDecapsulationKey` from draft-connolly-cfrg-xwing-kem-09 §3.2:
/// `SHAKE-256(seed, 96) → d(32) || z(32) || sk_X(32)`, then
/// `ML-KEM-768.KeyGen(d, z)` to produce the expanded decapsulation key.
///
/// `sk_X` is the last 32 bytes of the SHAKE expansion but is stored first in
/// LO's `SecretKey` encoding (`X25519_sk || ML-KEM-768_dk`).
///
/// Only needed to reconstruct known-answer test vectors. Production code uses
/// `keygen()` for freshly generated keys.
fn expand_draft09_seed(seed: &[u8; 32]) -> SecretKey {
use ml_kem::array::Array;
use ml_kem::{B32, EncodedSizeUser, KemCore, MlKem768};
use sha3::Shake256;
use sha3::digest::{ExtendableOutput, Update, XofReader};
use zeroize::Zeroize;
let mut expanded = [0u8; 96];
let mut hasher = Shake256::default();
hasher.update(seed.as_ref());
hasher.finalize_xof().read(&mut expanded);
// Layout per draft-09 §3.2:
// expanded[0..32] = d (first ML-KEM-768 KeyGen seed half)
// expanded[32..64] = z (second ML-KEM-768 KeyGen seed half)
// expanded[64..96] = skX (X25519 secret key)
let d: B32 = Array::from(<[u8; 32]>::try_from(&expanded[0..32]).unwrap());
let z: B32 = Array::from(<[u8; 32]>::try_from(&expanded[32..64]).unwrap());
let skx: [u8; 32] = expanded[64..96].try_into().unwrap();
expanded.zeroize();
let (dk, _ek) = MlKem768::generate_deterministic(&d, &z);
// dk.as_bytes() returns the 2400-byte expanded ML-KEM-768 decapsulation key.
let mlkem_bytes = dk.as_bytes().to_vec();
// LO encoding: X25519_sk (32) || ML-KEM-768_dk (2400) = 2432 bytes.
// skX is last in the SHAKE expansion but first in LO's SecretKey.
let mut sk_bytes = Vec::with_capacity(32 + mlkem_bytes.len());
sk_bytes.extend_from_slice(&skx);
sk_bytes.extend_from_slice(&mlkem_bytes);
SecretKey(sk_bytes)
}
#[test]
fn xwing_draft09_decap_kat() {
// draft-connolly-cfrg-xwing-kem-09 Appendix C, test vector 1.
// The 32-byte seed is expanded via SHAKE-256 into X25519 + ML-KEM-768
// key material (see expand_draft09_seed).
//
// NOTE: Appendix C carries a "TODO: replace with test vectors that re-use
// ML-KEM, X25519 values" annotation — these vectors may be revised in a
// later draft revision.
let seed: [u8; 32] =
hex!("7f9c2ba4e88f827d616045507605853ed73b8093f6efbc88eb1a6eacfa66ef26");
// Ciphertext: X25519_ct (32) || ML-KEM-768_ct (1088) = 1120 bytes.
let ct_bytes: [u8; 1120] = hex!(
"b83aa828d4d62b9a83ceffe1d3d3bb1ef31264643c070c5798927e41fb07914a273f8f96"
"e7826cd5375a283d7da885304c5de0516a0f0654243dc5b97f8bfeb831f68251219aabdd"
"723bc6512041acbaef8af44265524942b902e68ffd23221cda70b1b55d776a92d1143ea3"
"a0c475f63ee6890157c7116dae3f62bf72f60acd2bb8cc31ce2ba0de364f52b8ed38c79d"
"719715963a5dd3842d8e8b43ab704e4759b5327bf027c63c8fa857c4908d5a8a7b88ac7f"
"2be394d93c3706ddd4e698cc6ce370101f4d0213254238b4a2e8821b6e414a1cf20f6c12"
"44b699046f5a01caa0a1a55516300b40d2048c77cc73afba79afeea9d2c0118bdf2adb88"
"70dc328c5516cc45b1a2058141039e2c90a110a9e16b318dfb53bd49a126d6b73f215787"
"517b8917cc01cabd107d06859854ee8b4f9861c226d3764c87339ab16c3667d2f49384e5"
"5456dd40414b70a6af841585f4c90c68725d57704ee8ee7ce6e2f9be582dbee985e038ff"
"c346ebfb4e22158b6c84374a9ab4a44e1f91de5aac5197f89bc5e5442f51f9a5937b102b"
"a3beaebf6e1c58380a4a5fedce4a4e5026f88f528f59ffd2db41752b3a3d90efabe46389"
"9b7d40870c530c8841e8712b733668ed033adbfafb2d49d37a44d4064e5863eb0af0a08d"
"47b3cc888373bc05f7a33b841bc2587c57eb69554e8a3767b7506917b6b70498727f16ea"
"c1a36ec8d8cfaf751549f2277db277e8a55a9a5106b23a0206b4721fa9b3048552c5bd5b"
"594d6e247f38c18c591aea7f56249c72ce7b117afcc3a8621582f9cf71787e183dee0936"
"7976e98409ad9217a497df888042384d7707a6b78f5f7fb8409e3b535175373461b77600"
"2d799cbad62860be70573ecbe13b246e0da7e93a52168e0fb6a9756b895ef7f0147a0dc8"
"1bfa644b088a9228160c0f9acf1379a2941cd28c06ebc80e44e17aa2f8177010afd78a97"
"ce0868d1629ebb294c5151812c583daeb88685220f4da9118112e07041fcc24d5564a99f"
"dbde28869fe0722387d7a9a4d16e1cc8555917e09944aa5ebaaaec2cf62693afad42a3f5"
"18fce67d273cc6c9fb5472b380e8573ec7de06a3ba2fd5f931d725b493026cb0acbd3fe6"
"2d00e4c790d965d7a03a3c0b4222ba8c2a9a16e2ac658f572ae0e746eafc4feba023576f"
"08942278a041fb82a70a595d5bacbf297ce2029898a71e5c3b0d1c6228b485b1ade509b3"
"5fbca7eca97b2132e7cb6bc465375146b7dceac969308ac0c2ac89e7863eb8943015b243"
"14cafb9c7c0e85fe543d56658c213632599efabfc1ec49dd8c88547bb2cc40c9d38cbd30"
"99b4547840560531d0188cd1e9c23a0ebee0a03d5577d66b1d2bcb4baaf21cc7fef1e038"
"06ca96299df0dfbc56e1b2b43e4fc20c37f834c4af62127e7dae86c3c25a2f696ac8b589"
"dec71d595bfbe94b5ed4bc07d800b330796fda89edb77be0294136139354eb8cd3759157"
"8f9c600dd9be8ec6219fdd507adf3397ed4d68707b8d13b24ce4cd8fb22851bfe9d63240"
"7f31ed6f7cb1600de56f17576740ce2a32fc5145030145cfb97e63e0e41d354274a079d3"
"e6fb2e15"
);
let expected_ss: [u8; 32] =
hex!("d2df0522128f09dd8e2c92b1e905c793d8f57a54c3da25861f10bf4ca613e384");
// The draft's ciphertext encoding is ML-KEM-first: ctM (1088) || ctX (32).
// LO's encoding is X25519-first: ctX (32) || ctM (1088).
// Reorder to LO format before decapsulation.
let mut lo_ct = Vec::with_capacity(1120);
lo_ct.extend_from_slice(&ct_bytes[1088..]); // ctX: last 32 bytes of draft ct
lo_ct.extend_from_slice(&ct_bytes[..1088]); // ctM: first 1088 bytes of draft ct
let sk = expand_draft09_seed(&seed);
let ct = Ciphertext::from_bytes(lo_ct).unwrap();
let ss = decapsulate(&sk, &ct).unwrap();
assert_eq!(ss.as_bytes(), &expected_ss);
}
}

3618
soliton/src/ratchet/mod.rs Normal file

File diff suppressed because it is too large Load diff

1115
soliton/src/storage/mod.rs Normal file

File diff suppressed because it is too large Load diff

1927
soliton/src/streaming.rs Normal file

File diff suppressed because it is too large Load diff

243
soliton/src/verification.rs Normal file
View file

@ -0,0 +1,243 @@
//! Key verification phrases.
//!
//! Generates a human-readable phrase from two identity public keys that both
//! parties can compare out-of-band to verify identity key continuity.
//! This is a display-layer concern outside the formal cryptographic model;
//! the phrase is a human-readable encoding of the fingerprint comparison.
//!
//! Algorithm:
//! 1. Sort the two public keys lexicographically.
//! 2. Concatenate with domain separation: `"lo-verification-v1" || sorted_pk_a || sorted_pk_b`.
//! 3. Compute `hash = SHA3-256(concatenation)`.
//! 4. Map 7 chunks of the hash to words from the EFF large wordlist (7776 words).
//!
//! Each word carries ~12.9 bits of entropy. 7 words ≈ 90.3 bits of
//! second-preimage resistance (the relevant metric for pairwise verification:
//! given a specific key pair, how hard is it to find another pair producing the
//! same phrase). Birthday-bound collision resistance is ~45 bits.
use crate::constants;
use crate::error::{Error, Result};
use crate::primitives::sha3_256;
// Generated by build.rs from the EFF large wordlist.
include!(concat!(env!("OUT_DIR"), "/eff_wordlist.rs"));
const _: () = assert!(
EFF_WORDLIST.len() == 7776,
"EFF_WORDLIST must contain exactly 7776 words"
);
/// Generate a verification phrase from two identity public keys.
///
/// The phrase is deterministic and order-independent: `verification_phrase(a, b)`
/// produces the same result as `verification_phrase(b, a)`.
///
/// Returns 7 space-separated words from the EFF large wordlist.
#[must_use = "verification phrase must be displayed to the user"]
pub fn verification_phrase(pk_a: &[u8], pk_b: &[u8]) -> Result<String> {
if pk_a.len() != constants::LO_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_PUBLIC_KEY_SIZE,
got: pk_a.len(),
});
}
if pk_b.len() != constants::LO_PUBLIC_KEY_SIZE {
return Err(Error::InvalidLength {
expected: constants::LO_PUBLIC_KEY_SIZE,
got: pk_b.len(),
});
}
// Self-pair produces a valid phrase but verifies nothing — a UI bug
// passing the user's own key twice would give a false sense of security.
if pk_a == pk_b {
return Err(Error::InvalidData);
}
// Step 1: Sort keys lexicographically.
let (first, second) = if pk_a <= pk_b {
(pk_a, pk_b)
} else {
(pk_b, pk_a)
};
// Step 2: Concatenate with domain separation label and hash.
// The label prevents collisions with any other protocol function that
// hashes two concatenated public keys.
let mut input =
Vec::with_capacity(constants::PHRASE_HASH_LABEL.len() + first.len() + second.len());
input.extend_from_slice(constants::PHRASE_HASH_LABEL);
input.extend_from_slice(first);
input.extend_from_slice(second);
let mut hash = sha3_256::hash(&input);
// Step 3: Map hash bytes to word indices.
// We need 7 indices into a 7776-word list.
// Take 2 bytes per word (u16). Accept only values in 0..62208
// (62208 = 7776 * 8) to eliminate modular bias: val % 7776 is uniform
// for val in [0, 62208) because 62208 divides evenly. Rejection
// probability: 3328/65536 ≈ 5.1% per sample. Expected ~7.4 pairs
// consumed per phrase; ~8.6 spare slots in the first 32-byte hash
// before rehash. Across 20 rounds × 16 pairs = 320 samples,
// termination failure < 2^-150.
const LIMIT: u16 = 7776 * 8;
// Compile-time guard: if wordlist size or multiplier changes, the u16
// cast would silently truncate. This makes the invariant robust.
const _: () = assert!(7776 * 8 <= u16::MAX as u32);
let mut words = Vec::with_capacity(7);
let mut offset = 0;
let mut rehash_count = 0u32;
while words.len() < 7 {
// Rehash before reading if we've exhausted all 16 pairs (32 bytes).
if offset + 2 > 32 {
// Cap at 19 rehash rounds (range 1..=19). Each round has ~95%
// acceptance per sample, so failing to fill 7 words in
// 16 + 19 × 16 = 320 samples is astronomically improbable
// (~2^-150). This makes termination unconditional in the code
// rather than probabilistic.
rehash_count += 1;
if rehash_count >= 20 {
// Structurally unreachable: 320 samples with ~94.9% acceptance
// each → failure probability < 2^-150. Returns Internal rather
// than unreachable!() to preserve panic=abort safety.
return Err(Error::Internal);
}
// Include the round counter as a single byte to make each rehash
// round a distinct function, preventing degenerate hash chain cycles.
// rehash_count is in [1, 20], fits in u8. try_from makes this
// fail-fast if the cap is ever raised above 255.
let mut expand_input = Vec::with_capacity(19 + 1 + 32);
expand_input.extend_from_slice(constants::PHRASE_EXPAND_LABEL);
expand_input.push(u8::try_from(rehash_count).map_err(|_| Error::Internal)?);
expand_input.extend_from_slice(&hash);
hash = sha3_256::hash(&expand_input);
offset = 0;
}
let val = u16::from_be_bytes([hash[offset], hash[offset + 1]]);
offset += 2;
if val < LIMIT {
let index = (val as usize) % EFF_WORDLIST.len();
words.push(EFF_WORDLIST[index]);
}
}
Ok(words.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error::Error;
use crate::identity::{GeneratedIdentity, generate_identity};
#[test]
fn deterministic() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase1 = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let phrase2 = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
assert_eq!(phrase1, phrase2);
}
#[test]
fn order_independent() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let ab = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let ba = verification_phrase(pk_b.as_bytes(), pk_a.as_bytes()).unwrap();
assert_eq!(ab, ba);
}
#[test]
fn seven_words() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let words: Vec<&str> = phrase.split(' ').collect();
assert_eq!(words.len(), 7);
}
#[test]
fn all_words_in_wordlist() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let phrase = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
for word in phrase.split(' ') {
assert!(
EFF_WORDLIST.contains(&word),
"word '{}' not in EFF_WORDLIST",
word
);
}
}
#[test]
fn different_keys_differ() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
let GeneratedIdentity {
public_key: pk_c, ..
} = generate_identity().unwrap();
let phrase_ab = verification_phrase(pk_a.as_bytes(), pk_b.as_bytes()).unwrap();
let phrase_ac = verification_phrase(pk_a.as_bytes(), pk_c.as_bytes()).unwrap();
assert_ne!(phrase_ab, phrase_ac);
}
#[test]
fn wrong_pk_a_size() {
let GeneratedIdentity {
public_key: pk_b, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(&[0u8; 100], pk_b.as_bytes()),
Err(Error::InvalidLength {
expected: 3200,
got: 100
})
));
}
#[test]
fn wrong_pk_b_size() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(pk_a.as_bytes(), &[0u8; 100]),
Err(Error::InvalidLength {
expected: 3200,
got: 100
})
));
}
#[test]
fn self_pair_rejected() {
let GeneratedIdentity {
public_key: pk_a, ..
} = generate_identity().unwrap();
assert!(matches!(
verification_phrase(pk_a.as_bytes(), pk_a.as_bytes()),
Err(Error::InvalidData)
));
}
}