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
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:
commit
1d99048c95
165830 changed files with 79062 additions and 0 deletions
254
soliton/src/auth/mod.rs
Normal file
254
soliton/src/auth/mod.rs
Normal 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
691
soliton/src/call.rs
Normal 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
290
soliton/src/constants.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7776
soliton/src/eff_large_wordlist.txt
Normal file
7776
soliton/src/eff_large_wordlist.txt
Normal file
File diff suppressed because it is too large
Load diff
107
soliton/src/error.rs
Normal file
107
soliton/src/error.rs
Normal 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
635
soliton/src/identity/mod.rs
Normal 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
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
57
soliton/src/lib.rs
Normal 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;
|
||||
258
soliton/src/primitives/aead.rs
Normal file
258
soliton/src/primitives/aead.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
402
soliton/src/primitives/argon2.rs
Normal file
402
soliton/src/primitives/argon2.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
195
soliton/src/primitives/ed25519.rs
Normal file
195
soliton/src/primitives/ed25519.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
161
soliton/src/primitives/hkdf.rs
Normal file
161
soliton/src/primitives/hkdf.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
135
soliton/src/primitives/hmac.rs
Normal file
135
soliton/src/primitives/hmac.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
462
soliton/src/primitives/mldsa.rs
Normal file
462
soliton/src/primitives/mldsa.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
381
soliton/src/primitives/mlkem.rs
Normal file
381
soliton/src/primitives/mlkem.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
16
soliton/src/primitives/mod.rs
Normal file
16
soliton/src/primitives/mod.rs
Normal 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;
|
||||
67
soliton/src/primitives/random.rs
Normal file
67
soliton/src/primitives/random.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
82
soliton/src/primitives/sha3_256.rs
Normal file
82
soliton/src/primitives/sha3_256.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
222
soliton/src/primitives/x25519.rs
Normal file
222
soliton/src/primitives/x25519.rs
Normal 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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
770
soliton/src/primitives/xwing.rs
Normal file
770
soliton/src/primitives/xwing.rs
Normal 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
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
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
1927
soliton/src/streaming.rs
Normal file
File diff suppressed because it is too large
Load diff
243
soliton/src/verification.rs
Normal file
243
soliton/src/verification.rs
Normal 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)
|
||||
));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue