version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
Some checks failed
CI / lint (push) Failing after 52s
CI / test-python (push) Successful in 1m40s
CI / test (push) Has been cancelled
CI / test-wasm (push) Has been cancelled
CI / publish-wasm (push) Has been cancelled
CI / miri (push) Has been cancelled
CI / fuzz-regression (push) Has been cancelled
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled
CI / publish-python (push) Has been cancelled
CI / test-zig (push) Has been cancelled

This commit is contained in:
Kamal Tufekcic 2026-09-09 00:49:53 +03:00
commit 6853b30036
No known key found for this signature in database
16 changed files with 582 additions and 435 deletions

View file

@ -77,7 +77,19 @@ ed25519-dalek = { version = "=2.2.0", default-features = false, features = ["zer
# stacks are isolated at the type level and never share state.
ml-kem = { version = "=0.2.3", features = ["zeroize", "deterministic"] }
kem = "=0.3.0-pre.0"
ml-dsa = { version = "=0.1.0-rc.7", features = ["zeroize"] }
# `default-features = false` drops ml-dsa's `pkcs8` and `rand_core` features.
# soliton uses neither: keys and signatures cross the boundary as raw encodings
# (B32, EncodedVerifyingKey, EncodedSignature), and keygen seeds `from_seed`
# with bytes from soliton's own CSPRNG rather than a rand_core Rng.
#
# This is load-bearing, not tidiness. ml-dsa 0.1.0-rc.7 requires
# `pkcs8 = "0.11.0-rc.10"`, which Cargo reads as `>=0.11.0-rc.10, <0.12.0` — so
# once pkcs8 0.11.0 final shipped it outranked every rc and got selected, and
# ml-dsa's `pkcs8.rs` stopped compiling (`?` cannot convert into the reshaped
# `Error`). That broke a fresh `cargo add libsoliton`, since a library's
# Cargo.lock does not constrain downstream resolution. With the feature off,
# `pkcs8.rs` is never compiled and the breakage is unreachable.
ml-dsa = { version = "=0.1.0-rc.7", default-features = false, features = ["zeroize", "alloc"] }
# rand_core is pulled transitively by ml-kem (0.6) and ml-dsa (0.10).
# Deterministic/seed-based APIs + getrandom are used to avoid RNG trait issues.
@ -107,7 +119,9 @@ proptest = "=1.10.0"
rayon = "=1.11.0"
# ml-dsa is a direct dependency, re-declared here so integration tests can call
# sign_internal directly to produce deterministic F.27 test vectors (pinned rnd).
ml-dsa = { version = "=0.1.0-rc.7", features = ["zeroize"] }
# Feature set must match the [dependencies] entry — see the note there for why
# default features are off.
ml-dsa = { version = "=0.1.0-rc.7", default-features = false, features = ["zeroize", "alloc"] }
[[bench]]
name = "bench"

View file

@ -435,7 +435,7 @@ dependencies = [
[[package]]
name = "libsoliton"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"argon2",
"chacha20poly1305",

View file

@ -79,10 +79,12 @@ 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
/// Bound on the recv_seen set per epoch (§6.7): a live set holds fewer than
/// this many entries (at most 65535); `decrypt` refuses the insert that would
/// reach it and `to_bytes` rejects a set at or above it. 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.
/// values. The set stores only 4-byte counters (not keys), so the bound is
/// ~256 KB. Resets on each KEM ratchet step.
pub const MAX_RECV_SEEN: u32 = 65536;
/// Ratchet state serialization format version. Checked on deserialization;

View file

@ -16,10 +16,19 @@ use sha3::Sha3_256;
/// # 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.
/// key material is no longer needed.
///
/// What the dependencies zeroize, verified against the pinned sources
/// (`hkdf 0.13.0-rc.5`, `hmac 0.13.0-rc.5`, `sha3 0.11.0-rc.7`,
/// `digest 0.11.0-rc.11`, `block-buffer 0.11.0`, all with their `zeroize`
/// features resolved on): dropping the `Hkdf` zeroizes the PRK-keyed HMAC
/// state — both SHA3 cores through `sha3`'s `Drop` and the wrapper's block
/// buffer through `block-buffer`'s. `hkdf` itself has no `zeroize` feature
/// and leaves three plain stack copies behind: the raw 32-byte PRK that
/// `Hkdf::new` extracts and discards, the `key ^ OPAD` block that
/// `HmacCore::new_from_slice` builds in a local, and the last output block
/// that `expand` keeps in a local. All three are bounded to this function's
/// frame, and `panic = "abort"` prevents unwinding past it.
pub fn hkdf_sha3_256(salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<()> {
if out.is_empty() {
return Err(Error::InvalidLength {

View file

@ -302,6 +302,135 @@ pub fn verify(pk: &PublicKey, message: &[u8], signature: &Signature) -> Result<(
}
}
/// Sign with the FIPS 204 §5.2 ML-DSA.Sign envelope (domain separator +
/// context string).
///
/// This is the **standard ML-DSA.Sign** algorithm — equivalent to what
/// any FIPS 204-compliant implementation produces (rustls/aws-lc-rs,
/// OpenSSL once they ship PQ, hardware tokens). Use it whenever a
/// signature must verify under a generic ML-DSA-65 verifier.
///
/// Constructs `M' = 0x00 || len(ctx) || ctx || M` per FIPS 204 §5.2,
/// then signs M' via the underlying primitive. Distinct from
/// [`sign`], which signs M directly (no prefix) — soliton's
/// non-standard mode used by intra-Lo signatures that are always
/// verified by soliton itself.
///
/// `ctx` is the FIPS 204 context string. For TLS 1.3 use it MUST be
/// empty (`b""`) per `draft-ietf-tls-mldsa`. Other protocol-specific
/// contexts (e.g., `b"lo-session-bind"` for application-level domain
/// separation) are supported up to 255 bytes.
///
/// Hedged signing: 32 bytes of fresh OS entropy mixed into the nonce
/// derivation, identical to [`sign`]'s hedged behavior.
///
/// # Errors
/// - [`Error::InvalidData`] if `ctx.len() > 255`
/// - [`Error::InvalidLength`] if the secret-key seed length is wrong
pub fn sign_with_ctx(sk: &SecretKey, message: &[u8], ctx: &[u8]) -> Result<Signature> {
use ml_dsa::{B32, MlDsa65, Seed, SigningKey};
if ctx.len() > 255 {
return Err(Error::InvalidData);
}
if sk.0.len() != SK_LEN {
return Err(Error::InvalidLength {
expected: SK_LEN,
got: sk.0.len(),
});
}
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. Same
// rationale as `sign()` — fault-injection resistance.
let mut rnd_bytes = [0u8; 32];
super::random::random_bytes(&mut rnd_bytes);
let mut rnd: B32 = rnd_bytes.into();
rnd_bytes.zeroize();
// FIPS 204 §5.2 envelope: M' = 0x00 || len(ctx) || ctx || M.
// SHAKE's streaming absorb is associative, so passing M' as a
// multi-chunk slice (`&[domain_sep, len_byte, ctx, message]`)
// produces the same μ as concatenating into one buffer first.
// The `MuBuilder::new(tr, ctx).message(M)` path used by the
// ml-dsa crate's standard `verify_with_context` absorbs the
// same bytes in the same order — so signatures from this
// function verify under the standard verify path, AND the
// standard sign path verifies under our `verify_with_ctx`.
let domain_sep = [0x00u8];
let len_byte = [ctx.len() as u8];
let sig = signing_key.sign_internal(&[&domain_sep, &len_byte, ctx, message], &rnd);
rnd.zeroize();
let sig_bytes = sig.encode().to_vec();
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 with the FIPS 204 §5.2 ML-DSA.Verify envelope.
///
/// Counterpart of [`sign_with_ctx`]. Accepts signatures from any
/// FIPS 204-compliant ML-DSA-65 implementation (when called with
/// the same `ctx`).
///
/// `ctx` MUST match the context string used by the signer. For TLS
/// 1.3 use that is `b""`.
///
/// Returns `Ok(())` on a valid signature, `Err(VerificationFailed)`
/// for an invalid one or for `ctx.len() > 255`.
pub fn verify_with_ctx(
pk: &PublicKey,
message: &[u8],
ctx: &[u8],
signature: &Signature,
) -> Result<()> {
use ml_dsa::{EncodedSignature, EncodedVerifyingKey, MlDsa65, VerifyingKey};
if ctx.len() > 255 {
return Err(Error::VerificationFailed);
}
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(),
});
}
let vk_enc: &EncodedVerifyingKey<MlDsa65> =
pk.0.as_slice().try_into().map_err(|_| Error::Internal)?;
let vk = VerifyingKey::<MlDsa65>::decode(vk_enc);
let sig_enc: &EncodedSignature<MlDsa65> = signature
.0
.as_slice()
.try_into()
.map_err(|_| Error::Internal)?;
let sig = ml_dsa::Signature::<MlDsa65>::decode(sig_enc).ok_or(Error::VerificationFailed)?;
// `verify_with_context` applies the FIPS 204 §5.2 envelope
// (`0x00 || len(ctx) || ctx || M`) before verifying, matching
// the construction in `sign_with_ctx`.
if vk.verify_with_context(message, ctx, &sig) {
Ok(())
} else {
Err(Error::VerificationFailed)
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -335,6 +464,104 @@ mod tests {
assert!(verify(&pk, b"", &sig).is_ok());
}
/// `sign_with_ctx` round-trip with empty context (the form used
/// for TLS 1.3 cert verify per draft-ietf-tls-mldsa).
#[test]
fn sign_with_ctx_empty_round_trip() {
let (pk, sk) = keygen().unwrap();
let sig = sign_with_ctx(&sk, b"hello fips204", b"").unwrap();
assert!(verify_with_ctx(&pk, b"hello fips204", b"", &sig).is_ok());
}
/// Round-trip with a non-empty context. Confirms the FIPS 204
/// envelope `0x00 || len(ctx) || ctx || M` round-trips through
/// the sign/verify pair.
#[test]
fn sign_with_ctx_nonempty_round_trip() {
let (pk, sk) = keygen().unwrap();
let sig = sign_with_ctx(&sk, b"payload", b"app-domain-v1").unwrap();
assert!(verify_with_ctx(&pk, b"payload", b"app-domain-v1", &sig).is_ok());
}
/// Domain-separation: a signature produced with one context
/// MUST NOT verify under a different context. This is the
/// security property that makes per-protocol context strings
/// useful — a TLS-bound signature can't be replayed to an
/// application-layer verifier and vice versa.
#[test]
fn sign_with_ctx_different_ctx_fails() {
let (pk, sk) = keygen().unwrap();
let sig = sign_with_ctx(&sk, b"payload", b"ctx-a").unwrap();
assert!(matches!(
verify_with_ctx(&pk, b"payload", b"ctx-b", &sig),
Err(Error::VerificationFailed)
));
}
/// `sign` (no ctx) and `sign_with_ctx` produce DIFFERENT
/// signatures, and the signatures don't cross-verify. This is
/// the deliberate distinction: `sign` skips the FIPS 204
/// envelope, `sign_with_ctx` applies it. If a regression ever
/// made them equivalent, soliton's intra-Lo signatures would
/// silently become FIPS 204-compatible (or vice versa) — both
/// outcomes need to be intentional, not accidental.
#[test]
fn sign_and_sign_with_ctx_are_distinct() {
let (pk, sk) = keygen().unwrap();
let sig_internal = sign(&sk, b"payload").unwrap();
let sig_standard = sign_with_ctx(&sk, b"payload", b"").unwrap();
// Bytes differ (the rnd hedging makes equality unreliable
// even for identical inputs, so this isn't a strict check).
// The cross-verify check is the load-bearing one:
assert!(
matches!(
verify_with_ctx(&pk, b"payload", b"", &sig_internal),
Err(Error::VerificationFailed)
),
"internal-mode signature must NOT verify under FIPS 204 verify"
);
assert!(
matches!(
verify(&pk, b"payload", &sig_standard),
Err(Error::VerificationFailed)
),
"FIPS 204 signature must NOT verify under internal-mode verify"
);
}
/// 255-byte context is the spec maximum. Confirm it's accepted.
#[test]
fn sign_with_ctx_max_length_ok() {
let (pk, sk) = keygen().unwrap();
let ctx = vec![0xAB; 255];
let sig = sign_with_ctx(&sk, b"payload", &ctx).unwrap();
assert!(verify_with_ctx(&pk, b"payload", &ctx, &sig).is_ok());
}
/// 256-byte context exceeds the FIPS 204 §5.2 limit and MUST
/// be rejected at the API boundary rather than producing an
/// invalid signature.
#[test]
fn sign_with_ctx_oversized_ctx_rejected() {
let (_, sk) = keygen().unwrap();
let ctx = vec![0u8; 256];
assert!(matches!(
sign_with_ctx(&sk, b"payload", &ctx),
Err(Error::InvalidData)
));
}
#[test]
fn verify_with_ctx_oversized_ctx_rejected() {
let (pk, sk) = keygen().unwrap();
let valid_sig = sign_with_ctx(&sk, b"payload", b"").unwrap();
let too_long_ctx = vec![0u8; 256];
assert!(matches!(
verify_with_ctx(&pk, b"payload", &too_long_ctx, &valid_sig),
Err(Error::VerificationFailed)
));
}
#[test]
fn verify_wrong_message() {
let (pk, sk) = keygen().unwrap();

View file

@ -562,15 +562,15 @@ impl RatchetState {
// through key derivation + AEAD. Duplicates still succeed AEAD
// (same key, nonce, ciphertext) but the plaintext is discarded.
//
// Enforce MAX_RECV_SEEN cap at runtime, not just serialization time.
// Without this, an authenticated peer sending >65536 messages in one
// epoch would grow the set unboundedly and make the state unserializable.
// Enforce the serialization invariant (|recv_seen| < MAX_RECV_SEEN,
// §6.8) at runtime. The check is on the post-insert size so a successful
// decrypt never produces a live state that to_bytes would reject.
match epoch_type {
EpochType::PreviousEpoch => {
if self.prev_recv_seen.contains(&header.n) {
return Err(Error::DuplicateMessage);
}
if self.prev_recv_seen.len() >= constants::MAX_RECV_SEEN as usize {
if self.prev_recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize {
return Err(Error::ChainExhausted);
}
self.prev_recv_seen.insert(header.n);
@ -579,7 +579,7 @@ impl RatchetState {
if self.recv_seen.contains(&header.n) {
return Err(Error::DuplicateMessage);
}
if self.recv_seen.len() >= constants::MAX_RECV_SEEN as usize {
if self.recv_seen.len() + 1 >= constants::MAX_RECV_SEEN as usize {
return Err(Error::ChainExhausted);
}
self.recv_seen.insert(header.n);
@ -862,9 +862,10 @@ impl RatchetState {
// u64::MAX - 1 would write u64::MAX, which from_bytes rejects.
// u64::MAX is caught by to_bytes' checked_add (returns ChainExhausted).
&& self.epoch < u64::MAX - 1
// to_bytes() rejects recv_seen/prev_recv_seen exceeding MAX_RECV_SEEN.
// decrypt_inner enforces this cap at runtime, so these checks are
// defense-in-depth against a future refactor removing the runtime cap.
// to_bytes() rejects recv_seen/prev_recv_seen at MAX_RECV_SEEN or
// more. decrypt_inner refuses to grow the set past
// MAX_RECV_SEEN - 1 entries, so these checks are defense-in-depth
// against a refactor that removes the runtime cap.
&& self.recv_seen.len() < constants::MAX_RECV_SEEN as usize
&& self.prev_recv_seen.len() < constants::MAX_RECV_SEEN as usize
}
@ -1054,8 +1055,8 @@ impl RatchetState {
// recv_seen — sorted for deterministic output.
let mut sorted_seen: Vec<u32> = self.recv_seen.iter().copied().collect();
sorted_seen.sort_unstable();
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`):
// the set can hold at most MAX_RECV_SEEN - 1 entries (65535).
// Mirrors decrypt_inner's runtime cap: a live set holds at most
// MAX_RECV_SEEN - 1 entries (65535).
if sorted_seen.len() >= constants::MAX_RECV_SEEN as usize {
return Err(Error::InvalidData);
}
@ -1069,7 +1070,7 @@ impl RatchetState {
// prev_recv_seen — sorted for deterministic output.
let mut sorted_prev_seen: Vec<u32> = self.prev_recv_seen.iter().copied().collect();
sorted_prev_seen.sort_unstable();
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`).
// Mirrors decrypt_inner's runtime cap.
if sorted_prev_seen.len() >= constants::MAX_RECV_SEEN as usize {
return Err(Error::InvalidData);
}
@ -1286,8 +1287,8 @@ impl RatchetState {
// recv_seen set.
let num_seen =
u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?);
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`):
// the set can hold at most MAX_RECV_SEEN - 1 entries.
// Mirrors decrypt_inner's runtime cap: a live set holds at most
// MAX_RECV_SEEN - 1 entries.
if num_seen >= constants::MAX_RECV_SEEN {
return Err(Error::InvalidData);
}
@ -1311,7 +1312,7 @@ impl RatchetState {
// prev_recv_seen set.
let num_prev_seen =
u32::from_be_bytes(read(&mut pos, 4)?.try_into().map_err(|_| Error::Internal)?);
// Consistent with decrypt_inner's runtime cap (`>= MAX_RECV_SEEN`).
// Mirrors decrypt_inner's runtime cap.
if num_prev_seen >= constants::MAX_RECV_SEEN {
return Err(Error::InvalidData);
}
@ -3316,11 +3317,12 @@ mod tests {
#[test]
fn recv_seen_cap_at_runtime() {
let (mut alice, mut bob) = make_pair();
// Fill recv_seen to capacity with counters that won't collide
// with Alice's send_count (which starts at 1).
// Use range 100_000..100_000+MAX_RECV_SEEN to avoid collision.
bob.recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN).collect();
bob.recv_count = 100_000 + constants::MAX_RECV_SEEN;
// Fill recv_seen to the runtime cap (MAX_RECV_SEEN - 1 entries) with
// counters that won't collide with Alice's send_count (which starts at
// 1). The next decrypt would push the set to MAX_RECV_SEEN, violating
// the serialization invariant, so it must be rejected.
bob.recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN - 1).collect();
bob.recv_count = 100_000 + constants::MAX_RECV_SEEN - 1;
// Next decrypt should return ChainExhausted (not DuplicateMessage).
let enc = alice.encrypt(b"over-cap").unwrap();
assert!(
@ -3349,9 +3351,10 @@ mod tests {
send_a_to_b(&mut alice, &mut bob, b"dir2");
// enc_old is from Bob's previous recv epoch.
assert!(bob.prev_recv_epoch_key.is_some());
// Fill prev_recv_seen with counters that won't collide with
// enc_old's counter (which is 2 — setup=1, will-be-prev=2).
bob.prev_recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN).collect();
// Fill prev_recv_seen to the runtime cap (MAX_RECV_SEEN - 1 entries)
// with counters that won't collide with enc_old's counter (which is 2 —
// setup=1, will-be-prev=2).
bob.prev_recv_seen = (100_000..100_000 + constants::MAX_RECV_SEEN - 1).collect();
// Attempt to decrypt the old-epoch message.
assert!(
matches!(
@ -3435,8 +3438,8 @@ mod tests {
#[test]
fn encrypt_works_after_recv_seen_cap() {
let (mut alice, _) = make_pair();
alice.recv_seen = (0..constants::MAX_RECV_SEEN).collect();
alice.recv_count = constants::MAX_RECV_SEEN;
alice.recv_seen = (0..constants::MAX_RECV_SEEN - 1).collect();
alice.recv_count = constants::MAX_RECV_SEEN - 1;
// Encrypt must still work — recv_seen cap only affects decrypt.
assert!(alice.encrypt(b"still-works").is_ok());
}