version bumps, MAX_RECV_SEEN patch, FIPS interop, doc updates
All checks were successful
CI / test (push) Successful in 11m10s
CI / test-python (push) Successful in 1m10s
CI / test-zig (push) Successful in 1m8s
CI / test-wasm (push) Successful in 1m29s
CI / miri (push) Successful in 10m17s
CI / build (push) Successful in 1m5s
CI / lint (push) Successful in 1m17s
CI / fuzz-regression (push) Successful in 1h37m6s
CI / publish-python (push) Successful in 1m33s
CI / publish-wasm (push) Successful in 1m25s
CI / publish (push) Successful in 1m43s

This commit is contained in:
Kamal Tufekcic 2026-09-09 00:59:39 +03:00
commit cb5a7cef57
No known key found for this signature in database
17 changed files with 584 additions and 437 deletions

View file

@ -646,14 +646,11 @@ recv_seen, prev_recv_seen, local_fp, remote_fp, epoch)
- (d) s = 0 ∧ sk_s set is unreachable. - (d) s = 0 ∧ sk_s set is unreachable.
- (e) s = 0 ∧ pending = ⊥ ∧ pk_r set ∧ sk_s unset is unreachable. - (e) s = 0 ∧ pending = ⊥ ∧ pk_r set ∧ sk_s unset is unreachable.
- (f) prev_ek_r is set iff prev_pk_r is set (co-presence). - (f) prev_ek_r is set iff prev_pk_r is set (co-presence).
- (g) |recv_seen| ≤ MAX_RECV_SEEN; |prev_recv_seen| ≤ MAX_RECV_SEEN. Note: the - (g) |recv_seen| < MAX_RECV_SEEN; |prev_recv_seen| < MAX_RECV_SEEN. The
runtime bound allows recv_seen to reach exactly MAX_RECV_SEEN entries (the runtime check (§5.4 Step 6) rejects when a successful insert would push the
check-before-insert pattern at §5.4 Step 6 passes at MAX_RECV_SEEN − 1, then set to MAX_RECV_SEEN entries, so the live invariant matches the serialization
the insert produces MAX_RECV_SEEN). However, a state with MAX_RECV_SEEN invariant: no reachable live state has MAX_RECV_SEEN or more entries, and
entries cannot be serialized — serialization rejects at |recv_seen| ≥ every live state is serializable on this dimension.
MAX_RECV_SEEN. The serialization invariant is therefore |recv_seen| <
MAX_RECV_SEEN. Models reasoning about serialization should use the strict
bound.
- (h) local_fp ≠ remote_fp (self-messaging rejected at construction). - (h) local_fp ≠ remote_fp (self-messaging rejected at construction).
Additionally, local_fp ≠ 0³² and remote_fp ≠ 0³² (all-zero fingerprint Additionally, local_fp ≠ 0³² and remote_fp ≠ 0³² (all-zero fingerprint
rejected at construction; constant-time check). Under SHA3-256 modeled as a rejected at construction; constant-time check). Under SHA3-256 modeled as a
@ -910,7 +907,7 @@ symmetric rollback for both operations produces an incorrect model.
replaying a legitimate ciphertext; AEAD succeeds but the plaintext is replaying a legitimate ciphertext; AEAD succeeds but the plaintext is
discarded). These must be modeled as separate failure branches with different discarded). These must be modeled as separate failure branches with different
adversary capabilities. adversary capabilities.
6. If |seen_set| ≥ MAX_RECV_SEEN: fail (ChainExhausted). seen_set ← seen_set ∪ 6. If |seen_set| + 1 ≥ MAX_RECV_SEEN: fail (ChainExhausted). seen_set ← seen_set ∪
{n\*}. If epoch is CurrentEpoch or NewEpoch: r ← max(r, n\* + 1). {n\*}. If epoch is CurrentEpoch or NewEpoch: r ← max(r, n\* + 1).
(PreviousEpoch messages do not update r — they belong to a prior receive (PreviousEpoch messages do not update r — they belong to a prior receive
epoch, and updating the current-epoch high-water mark would corrupt epoch, and updating the current-epoch high-water mark would corrupt

View file

@ -294,7 +294,7 @@ Mistakes that compile successfully but produce incorrect or insecure behavior. A
| `Internal` | -12 | `SOLITON_ERR_INTERNAL` | Structurally unreachable internal invariant violated (bug in soliton) | | `Internal` | -12 | `SOLITON_ERR_INTERNAL` | Structurally unreachable internal invariant violated (bug in soliton) |
| `NullPointer` | -13 | `SOLITON_ERR_NULL_POINTER` | Null pointer passed for a required argument *(CAPI-only)* | | `NullPointer` | -13 | `SOLITON_ERR_NULL_POINTER` | Null pointer passed for a required argument *(CAPI-only)* |
| `UnsupportedFlags` | -14 | `SOLITON_ERR_FLAGS` | *(reserved — not currently returned; see footnote)* | | `UnsupportedFlags` | -14 | `SOLITON_ERR_FLAGS` | *(reserved — not currently returned; see footnote)* |
| `ChainExhausted` | -15 | `SOLITON_ERR_CHAIN_EXHAUSTED` | Counter-space exhausted. Sources: ratchet send/recv counter reaches u32::MAX; call key chain advance limit (2²⁴ = 16,777,216 steps); per-epoch recv_seen set reaches 65536 distinct entries; streaming chunk index reaches u64::MAX | | `ChainExhausted` | -15 | `SOLITON_ERR_CHAIN_EXHAUSTED` | Counter-space exhausted. Sources: ratchet send/recv counter reaches u32::MAX; call key chain advance limit (2²⁴ = 16,777,216 steps); per-epoch recv_seen set would grow past 65535 distinct entries; streaming chunk index reaches u64::MAX |
| `UnsupportedCryptoVersion` | -16 | `SOLITON_ERR_CRYPTO_VERSION` | Peer advertised a crypto version this library does not implement | | `UnsupportedCryptoVersion` | -16 | `SOLITON_ERR_CRYPTO_VERSION` | Peer advertised a crypto version this library does not implement |
| `InvalidData` | -17 | `SOLITON_ERR_INVALID_DATA` | Structurally invalid content (format error, co-presence violation, implausible values) | | `InvalidData` | -17 | `SOLITON_ERR_INVALID_DATA` | Structurally invalid content (format error, co-presence violation, implausible values) |
| `ConcurrentAccess` | -18 | `SOLITON_ERR_CONCURRENT_ACCESS` | Opaque handle is in use by another concurrent operation (reentrancy guard) *(CAPI-only)* | | `ConcurrentAccess` | -18 | `SOLITON_ERR_CONCURRENT_ACCESS` | Opaque handle is in use by another concurrent operation (reentrancy guard) *(CAPI-only)* |
@ -420,7 +420,7 @@ All constants have type `&[u8]` (byte-string literals). In Rust, use `b"..."` no
| `CALL_KEY_B_BYTE` | `&[u8]` | `&[0x05]` | Call key derivation, second call key | | `CALL_KEY_B_BYTE` | `&[u8]` | `&[0x05]` | Call key derivation, second call key |
| `CALL_CHAIN_ADV_BYTE` | `&[u8]` | `&[0x06]` | Call chain key advance | | `CALL_CHAIN_ADV_BYTE` | `&[u8]` | `&[0x06]` | Call chain key advance |
| `HKDF_ZERO_SALT` | `[u8; 32]` | `[0u8; 32]` | LO-KEX HKDF salt (§5.4 Step 4) — 32 zero bytes | | `HKDF_ZERO_SALT` | `[u8; 32]` | `[0u8; 32]` | LO-KEX HKDF salt (§5.4 Step 4) — 32 zero bytes |
| `MAX_RECV_SEEN` | `u32` | `65536` | recv_seen set cap per epoch; 65536th distinct-counter decrypt succeeds, 65537th returns `ChainExhausted` | | `MAX_RECV_SEEN` | `u32` | `65536` | recv_seen set bound per epoch: a live set holds at most 65535 entries (`len < MAX_RECV_SEEN`); the 65536th distinct-counter decrypt returns `ChainExhausted` |
| `MAX_CALL_ADVANCE` *(internal, not pub)* | `u32` | `16,777,216` (2²⁴) | `CallKeys::advance()` limit; `ChainExhausted` after this many calls. Not in `soliton::constants` — value for reimplementors only. | | `MAX_CALL_ADVANCE` *(internal, not pub)* | `u32` | `16,777,216` (2²⁴) | `CallKeys::advance()` limit; `ChainExhausted` after this many calls. Not in `soliton::constants` — value for reimplementors only. |
--- ---
@ -1313,7 +1313,7 @@ pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<EncryptedMessage>
#[must_use = "decrypted plaintext contains sensitive data that must be consumed or zeroized"] #[must_use = "decrypted plaintext contains sensitive data that must be consumed or zeroized"]
pub fn decrypt(&mut self, header: &RatchetHeader, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>> pub fn decrypt(&mut self, header: &RatchetHeader, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>>
// Err(DuplicateMessage) — counter already in recv_seen (state unchanged) // Err(DuplicateMessage) — counter already in recv_seen (state unchanged)
// Err(ChainExhausted) — counter at u32::MAX, or recv_seen / prev_recv_seen set reached 65536 entries // Err(ChainExhausted) — counter at u32::MAX, or recv_seen / prev_recv_seen already holds 65535 entries
// Err(InvalidData) — ratchet state previously zeroized by reset() or a prior AeadFailed (requires new KEX); // Err(InvalidData) — ratchet state previously zeroized by reset() or a prior AeadFailed (requires new KEX);
// or ratchet step required but no KEM ct in header; or no send secret key // or ratchet step required but no KEM ct in header; or no send secret key
// Err(DecapsulationFailed) — X-Wing KEM decapsulation failed // Err(DecapsulationFailed) — X-Wing KEM decapsulation failed
@ -1348,8 +1348,8 @@ pub fn to_bytes(self) -> Result<(Zeroizing<Vec<u8>>, u64)> // consumes self; re
// there is no recovery path. Call can_serialize() first; if false, do NOT call to_bytes(). // there is no recovery path. Call can_serialize() first; if false, do NOT call to_bytes().
// Err(ChainExhausted) — send_count, recv_count, prev_send_count == u32::MAX, or epoch == u64::MAX // Err(ChainExhausted) — send_count, recv_count, prev_send_count == u32::MAX, or epoch == u64::MAX
// Err(InvalidData) — recv_seen or prev_recv_seen has ≥ 65536 entries (defense-in-depth dead branch: // Err(InvalidData) — recv_seen or prev_recv_seen has ≥ 65536 entries (defense-in-depth dead branch:
// decrypt's ChainExhausted guard fires at recv_seen.len() == 65536, preventing the set from growing // decrypt's ChainExhausted guard refuses the insert that would make recv_seen.len() == 65536, so a
// further, so this InvalidData path is unreachable in normal operation; the global error table lists // live set never exceeds 65535 and this InvalidData path is unreachable in normal operation; the global error table lists
// recv_seen exhaustion under ChainExhausted for this reason) // recv_seen exhaustion under ChainExhausted for this reason)
#[deprecated(note = "Use from_bytes_with_min_epoch for anti-rollback protection")] #[deprecated(note = "Use from_bytes_with_min_epoch for anti-rollback protection")]
pub fn from_bytes(data: &[u8]) -> Result<Self> // no rollback protection — always use from_bytes_with_min_epoch pub fn from_bytes(data: &[u8]) -> Result<Self> // no rollback protection — always use from_bytes_with_min_epoch

654
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -14,7 +14,7 @@ default-members = [
] ]
[workspace.package] [workspace.package]
version = "0.1.3" version = "0.1.4"
edition = "2024" edition = "2024"
rust-version = "1.87" rust-version = "1.87"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"

View file

@ -1416,7 +1416,7 @@ function Decrypt(state, header, ciphertext, sender_fp, recipient_fp):
if header.n in state.prev_recv_seen: if header.n in state.prev_recv_seen:
restore_recv_state(state, snapshot) restore_recv_state(state, snapshot)
raise DuplicateMessage raise DuplicateMessage
if |state.prev_recv_seen| >= MAX_RECV_SEEN: if |state.prev_recv_seen| + 1 >= MAX_RECV_SEEN:
restore_recv_state(state, snapshot) restore_recv_state(state, snapshot)
raise ChainExhausted raise ChainExhausted
state.prev_recv_seen.add(header.n) state.prev_recv_seen.add(header.n)
@ -1434,7 +1434,7 @@ function Decrypt(state, header, ciphertext, sender_fp, recipient_fp):
if header.n in state.recv_seen: if header.n in state.recv_seen:
restore_recv_state(state, snapshot) restore_recv_state(state, snapshot)
raise DuplicateMessage raise DuplicateMessage
if |state.recv_seen| >= MAX_RECV_SEEN: if |state.recv_seen| + 1 >= MAX_RECV_SEEN:
restore_recv_state(state, snapshot) restore_recv_state(state, snapshot)
raise ChainExhausted raise ChainExhausted
state.recv_seen.add(header.n) state.recv_seen.add(header.n)
@ -1586,7 +1586,7 @@ Ratchet state is serialized for encrypted persistent storage. The caller MUST au
**Serialization buffer zeroization**: The serialization output buffer contains root keys, epoch keys, and ratchet secret keys — all secret material. Implementations SHOULD pre-allocate the buffer to its exact final size before writing any data. If a dynamic array (Vec, list, slice) reallocates during serialization, the abandoned allocation containing partial secret material is freed to the heap allocator without zeroization — only the final allocation is covered by zeroize-on-drop. In Rust, pre-allocation is the actual guarantee; a `debug_assert` on capacity detects underestimates during testing but is compiled out in release builds — if the pre-computed size is wrong, a release binary silently reallocates and the abandoned partial allocation is freed without zeroization. In C/Go/Python, pre-compute the buffer size and allocate once. The output buffer itself MUST be zeroized after the caller has finished with it (e.g., after passing it to storage encryption). **Serialization buffer zeroization**: The serialization output buffer contains root keys, epoch keys, and ratchet secret keys — all secret material. Implementations SHOULD pre-allocate the buffer to its exact final size before writing any data. If a dynamic array (Vec, list, slice) reallocates during serialization, the abandoned allocation containing partial secret material is freed to the heap allocator without zeroization — only the final allocation is covered by zeroize-on-drop. In Rust, pre-allocation is the actual guarantee; a `debug_assert` on capacity detects underestimates during testing but is compiled out in release builds — if the pre-computed size is wrong, a release binary silently reallocates and the abandoned partial allocation is freed without zeroization. In C/Go/Python, pre-compute the buffer size and allocate once. The output buffer itself MUST be zeroized after the caller has finished with it (e.g., after passing it to storage encryption).
**MAX_RECV_SEEN cap at runtime**: When `recv_seen` or `prev_recv_seen` reaches `MAX_RECV_SEEN` (65536) entries during decrypt, subsequent messages in that epoch return `ChainExhausted`. This is transient — the cap resets on the next KEM ratchet step (which clears `recv_seen`). The cap prevents unbounded memory growth from an authenticated peer sending many messages in a single epoch. **MAX_RECV_SEEN cap at runtime**: When `recv_seen` or `prev_recv_seen` reaches `MAX_RECV_SEEN - 1` (65535) entries during decrypt, the next message in that epoch returns `ChainExhausted` — the runtime check rejects when a successful insert would push the set to `MAX_RECV_SEEN`, matching the serialization invariant (`|recv_seen| < MAX_RECV_SEEN`). This is transient — the cap resets on the next KEM ratchet step (which clears `recv_seen`). The cap prevents unbounded memory growth from an authenticated peer sending many messages in a single epoch.
**Which epoch paths can fire this cap**: The `recv_seen` saturation check (`ChainExhausted`) fires only on the **current-epoch path** (messages in the active receive epoch) and the **previous-epoch path** (messages in the prior epoch, checked against `prev_recv_seen`). The **new-epoch path** (which triggers a KEM ratchet step) is immune: it clears `recv_seen` to empty before the duplicate/cap check, so the cap check on that path is structurally unreachable — a new epoch always starts with an empty `recv_seen`. A reimplementer testing `ChainExhausted` from `recv_seen` saturation MUST use the current-epoch or previous-epoch path, not the new-epoch path. **Which epoch paths can fire this cap**: The `recv_seen` saturation check (`ChainExhausted`) fires only on the **current-epoch path** (messages in the active receive epoch) and the **previous-epoch path** (messages in the prior epoch, checked against `prev_recv_seen`). The **new-epoch path** (which triggers a KEM ratchet step) is immune: it clears `recv_seen` to empty before the duplicate/cap check, so the cap check on that path is structurally unreachable — a new epoch always starts with an empty `recv_seen`. A reimplementer testing `ChainExhausted` from `recv_seen` saturation MUST use the current-epoch or previous-epoch path, not the new-epoch path.

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. # stacks are isolated at the type level and never share state.
ml-kem = { version = "=0.2.3", features = ["zeroize", "deterministic"] } ml-kem = { version = "=0.2.3", features = ["zeroize", "deterministic"] }
kem = "=0.3.0-pre.0" 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). # 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. # Deterministic/seed-based APIs + getrandom are used to avoid RNG trait issues.
@ -107,7 +119,9 @@ proptest = "=1.10.0"
rayon = "=1.11.0" rayon = "=1.11.0"
# ml-dsa is a direct dependency, re-declared here so integration tests can call # 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). # 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]] [[bench]]
name = "bench" name = "bench"

View file

@ -435,7 +435,7 @@ dependencies = [
[[package]] [[package]]
name = "libsoliton" name = "libsoliton"
version = "0.1.3" version = "0.1.4"
dependencies = [ dependencies = [
"argon2", "argon2",
"chacha20poly1305", "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). /// Zero salt for HKDF in LO-KEX (§5.4 Step 4).
pub const HKDF_ZERO_SALT: [u8; 32] = [0u8; 32]; 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 /// exhaustion from an adversary sending messages with many distinct counter
/// values. The set stores only 4-byte counters (not keys), so 65536 entries /// values. The set stores only 4-byte counters (not keys), so the bound is
/// is ~256 KB. Resets on each KEM ratchet step. /// ~256 KB. Resets on each KEM ratchet step.
pub const MAX_RECV_SEEN: u32 = 65536; pub const MAX_RECV_SEEN: u32 = 65536;
/// Ratchet state serialization format version. Checked on deserialization; /// Ratchet state serialization format version. Checked on deserialization;

View file

@ -16,10 +16,19 @@ use sha3::Sha3_256;
/// # Security /// # Security
/// ///
/// The caller is responsible for zeroizing the `out` buffer when the derived /// 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 /// key material is no longer needed.
/// **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 /// What the dependencies zeroize, verified against the pinned sources
/// this function scope, and `panic = "abort"` prevents unwinding. /// (`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<()> { pub fn hkdf_sha3_256(salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<()> {
if out.is_empty() { if out.is_empty() {
return Err(Error::InvalidLength { return Err(Error::InvalidLength {

View file

@ -302,6 +302,136 @@ 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];
// `ctx.len() > 255` was rejected above, so this cannot fail.
let len_byte = [u8::try_from(ctx.len()).map_err(|_| Error::Internal)?];
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -335,6 +465,104 @@ mod tests {
assert!(verify(&pk, b"", &sig).is_ok()); 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] #[test]
fn verify_wrong_message() { fn verify_wrong_message() {
let (pk, sk) = keygen().unwrap(); let (pk, sk) = keygen().unwrap();

View file

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

View file

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

View file

@ -18,5 +18,5 @@ crate-type = ["cdylib"]
[dependencies] [dependencies]
libsoliton = { workspace = true } libsoliton = { workspace = true }
pyo3 = { version = "0.28", features = ["extension-module"] } pyo3 = { version = "0.29", features = ["extension-module"] }
zeroize = { workspace = true } zeroize = { workspace = true }

View file

@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "soliton-py" name = "soliton-py"
version = "0.1.3" version = "0.1.4"
description = "Python bindings for libsoliton — post-quantum cryptographic library" description = "Python bindings for libsoliton — post-quantum cryptographic library"
license = { text = "AGPL-3.0-only" } license = { text = "AGPL-3.0-only" }
requires-python = ">=3.10" requires-python = ">=3.10"

View file

@ -1,6 +1,6 @@
[package] [package]
name = "soliton-wasm" name = "soliton-wasm"
version = "0.1.3" version = "0.1.4"
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
repository.workspace = true repository.workspace = true

View file

@ -24,7 +24,7 @@ Add as a Zig dependency in `build.zig.zon`:
```zig ```zig
.dependencies = .{ .dependencies = .{
.soliton = .{ .soliton = .{
.url = "https://git.lo.sh/lo/libsoliton/archive/v0.1.3.tar.gz", .url = "https://git.lo.sh/lo/libsoliton/archive/v0.1.4.tar.gz",
.hash = "...", .hash = "...",
}, },
}, },

View file

@ -1,6 +1,6 @@
.{ .{
.name = .soliton_zig, .name = .soliton_zig,
.version = "0.1.3", .version = "0.1.4",
.fingerprint = 0x333e453f9c21f54c, .fingerprint = 0x333e453f9c21f54c,
.minimum_zig_version = "0.14.0", .minimum_zig_version = "0.14.0",
.paths = .{ .paths = .{