Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
704 KiB
Soliton Cryptographic Specification
1. Overview
Companion to LO Protocol Specification v1. Specifies all cryptographic protocols for authentication, key agreement, message encryption, signatures, and storage.
1.1 Design Philosophy
- Unified key type: Identity = LO composite (X25519 + ML-KEM-768 + ML-DSA-65). Pre-keys = X-Wing (X25519 + ML-KEM-768).
- KEM-native: Key agreement via KEM, not Diffie-Hellman.
- Hybrid everything: Classical + post-quantum for encryption and signatures.
- Header-bound AAD: All DM ciphertext authentication binds the full message header, preventing header tampering.
- Memory-safe C ABI: Rust core library with a stable C ABI (
soliton_capi). All language bindings call through this ABI. - Versioned primitives: Crypto version tag on all key material and sessions.
1.2 Primitives (lo-crypto-v1)
| Primitive | Algorithm | Reference |
|---|---|---|
| Hybrid KEM | X-Wing (X25519 + ML-KEM-768) | draft-connolly-cfrg-xwing-kem-09 |
| Classical KEM | X25519 | RFC 7748 |
| Post-quantum KEM | ML-KEM-768 | FIPS 203 |
| Classical signature | Ed25519 | RFC 8032 |
| Post-quantum signature | ML-DSA-65 | FIPS 204 |
| KDF | HKDF-SHA3-256 | RFC 5869 |
| Hash | SHA3-256 | FIPS 202 |
| Symmetric | XChaCha20-Poly1305 | RFC 8439 + HChaCha20 extension |
| MAC | HMAC-SHA3-256 | RFC 2104 |
| Password KDF | Argon2id | RFC 9106 |
| Storage compression | Zstandard (zstd) | RFC 8878 |
1.3 Backend
The core library is pure Rust with zero C dependencies:
| Crate | Algorithms |
|---|---|
curve25519-dalek |
X25519 (RFC 7748) |
ed25519-dalek |
Ed25519 signing/verification (RFC 8032) |
ml-kem |
ML-KEM-768 (FIPS 203) |
ml-dsa |
ML-DSA-65 (FIPS 204) |
chacha20poly1305 |
XChaCha20-Poly1305 (RFC 8439 + HChaCha20) |
sha3 |
SHA3-256 |
hmac, hkdf |
HMAC-SHA3-256, HKDF-SHA3-256 |
ruzstd |
Zstandard compression/decompression (§11, pure Rust) |
argon2 |
Argon2id password-based key derivation (RFC 9106, §10.6) |
getrandom |
CSPRNG (OS entropy: getrandom(2), ProcessPrng, getentropy, etc.) |
All dependencies are exact-pinned. No C toolchain, cmake, or pkg-config required. Compiles with cargo build on any target including wasm32-unknown-unknown.
1.4 Notation
|| Concatenation of byte strings
x[a..b] Half-open byte range: byte index a inclusive to b exclusive. x[0..32] selects 32 bytes (indices 0-31). Equivalent to x[a:b] in Python/Go, x.slice(a, b) in Rust, Arrays.copyOfRange(x, a, b) in Java. Programmers accustomed to inclusive-end notation must treat b as "one past the last index."
len(x) Length of x in bytes, encoded as 2-byte big-endian (the prefix encodes the byte count of x itself; the 2-byte prefix is not included in the value)
big_endian_32(x) 4-byte big-endian encoding of unsigned 32-bit integer x. Not the same as len(x) — big_endian_32 always writes exactly 4 bytes and does not encode x as a length prefix.
HKDF(salt, ikm, info, len) HKDF-SHA3-256 extract-and-expand (always both steps: RFC 5869 §2.2 Extract + §2.3 Expand. HKDF-Expand-only is never used.)
XWing.KeyGen() → (pk, sk)
XWing.Encaps(pk) → (ciphertext, shared_secret)
XWing.Decaps(sk, ct) → shared_secret // re-derives pk_X = X25519(sk_X, G) internally (the decapsulator's
// OWN public key — NOT ct_X from the ciphertext; see §8.2 for the
// most common X-Wing implementation error)
Ed25519.Sign(ed25519_sk, msg) → sig (64 bytes)
Ed25519.Verify(ed25519_pk, msg, sig) → bool
MLDSA.Sign(sk, msg) → sig (3309 bytes, hedged mode: FIPS 204 §6.2 Sign_internal with rnd=random(32))
// §6.2 is Sign_internal (the deterministic core); §5.2 is the external ML-DSA.Sign wrapper.
// sk is the 32-byte seed ξ — re-expanded per §8.5 before use. Not the 4032-byte expanded signing key.
MLDSA.Verify(pk, msg, sig) → bool (Verify_internal per FIPS 204 §6.3 — see §3.1)
// §6.3 is Verify_internal (the deterministic core); §5.3 is the external ML-DSA.Verify wrapper.
HMAC-SHA3-256(key, data) → 32-byte tag (first argument is always the HMAC key, second is the message)
AEAD(key, nonce, pt, aad) → ciphertext || tag (XChaCha20-Poly1305)
random_bytes(n) → n cryptographically random bytes (OS CSPRNG via getrandom)
encode_session_init(h) → deterministic binary (§7.4)
encode_ratchet_header(h) → deterministic binary (§7.4)
SHA3-256(x) → 32-byte digest (FIPS 202; not Ethereum's Keccak-256, which uses 0x01 padding — FIPS 202 uses 0x06)
Byte comparison convention: All lexicographic comparisons of byte strings throughout this spec (fingerprint sorting in §6.12, §9.2; key sorting in §9.2) use unsigned byte-by-byte comparison. Languages with signed byte types (Java byte, some C char implementations) must cast to unsigned before comparison — signed comparison reverses the ordering for bytes ≥ 0x80, producing different sort results and silently wrong AAD or verification phrases.
1.5 Channel 2 Scope (Metadata Exposure)
This library fully protects Channel 1 — the content and integrity of transmitted data: message confidentiality, authentication, forward secrecy, and replay prevention. It makes no guarantees about Channel 2 — the structural metadata of communication: who communicates with whom, when, how often, and in what pattern. This is an explicit design boundary, not a gap.
The following information is observable to a passive network adversary (one who can intercept but not modify traffic) and is out of scope for all security properties claimed in this document:
LO-KEX (§5)
- Bundle fetch: the bundle relay server learns that party A intends to contact party B before any encryption begins.
- Session initialization: the
SessionInitmessage reveals that two specific fingerprints are beginning a session to any observer who can intercept it. - Failed session attempts: a responder that rejects a
SessionInit(wrong crypto version, structural error) responds differently from one that never received it. An initiator can probe whether a party is online or running a specific version by observing response presence and timing. Probing resistance requires transport-layer measures outside this library's scope.
LO-Ratchet (§6)
- Epoch transitions:
pk_sin the cleartext header changes at each KEM ratchet step — a network observer can determine when a direction change occurred and count how many ratchet steps have taken place. - Message position: the counter
nin the cleartext header reveals the message's position within the current epoch. - Previous epoch size:
pnreveals how many messages were sent in the preceding epoch. - Ciphertext length: approximates plaintext length (compressed plaintext + 17-byte AEAD overhead). When compression is enabled, length leaks plaintext compressibility.
LO-Auth (§4)
- Challenge issuance: the challenge ciphertext is sent in cleartext; its issuance reveals that an authentication attempt is in progress between a specific client and server.
Streaming AEAD (§11)
- Stream header:
base_nonce,version, andflagsare transmitted in cleartext — their presence reveals that a stream is being established. - Chunk count: the number of chunks is observable from the ciphertext stream structure.
- Chunk sizes: approximate plaintext chunk sizes (compressed size + 17-byte overhead per chunk).
Designing for Channel 2 protection: Applications requiring metadata privacy must add transport-layer measures on top of this library. Uniform message padding (all messages padded to fixed sizes) removes length leakage. Cover traffic removes frequency and timing leakage. Onion routing or a mix network removes connection-graph leakage. An encrypted transport tunnel wrapping LO-Ratchet output removes epoch-transition leakage from the header fields. These concerns are outside the scope of this library.
2. LO Composite Key
2.1 Key Generation
function GenerateIdentity():
(xwing_pk, xwing_sk) = XWing.KeyGen()
(mldsa_pk, mldsa_sk_expanded) = MLDSA.KeyGen()
mldsa_sk = mldsa_sk_expanded.to_seed() // Extract 32-byte seed ξ — NOT the 4032-byte expanded key (FIPS 204 §7.2, ML-DSA-65 sigKeySize)
(ed25519_pk, ed25519_sk) = Ed25519.KeyGen()
pk = xwing_pk || ed25519_pk || mldsa_pk // 1216 + 32 + 1952 = 3200 bytes
sk = xwing_sk || ed25519_sk || mldsa_sk // 2432 (expanded X-Wing sk — see §8.5) + 32 + 32 = 2496 bytes
fingerprint = hex(SHA3-256(pk)) // 32 bytes = 64 lowercase hex chars (a-f, 0-9)
return (pk, sk, fingerprint)
MLDSA.KeyGen() returns an expanded key — to_seed() extracts the 32-byte seed before storage: Most ML-DSA library APIs return the fully expanded 4032 (FIPS 204 §7.2, ML-DSA-65 sigKeySize)-byte signing key as mldsa_sk. soliton stores only the 32-byte seed ξ (§8.5). The to_seed() step extracts ξ from the expanded form before assembly into the 2496-byte composite secret key. Alternative (seed-first) pattern used by the reference implementation: The reference does NOT call MLDSA.KeyGen() and then to_seed() — instead it generates ξ = random_bytes(32) directly from the OS CSPRNG and calls ML-DSA.KeyGen_internal(ξ) (FIPS 204 §6.1, a deterministic function of ξ) to obtain both the public key and the signing handle, without ever creating or discarding the expanded 4032-byte key. This is the pattern described in §2.1's "If your ML-DSA library does not expose ξ after KeyGen() at all" paragraph. Both patterns produce the same stored 32-byte seed and are cryptographically equivalent; the seed-first pattern is cleaner and avoids the to_seed() extraction step. The pseudocode above shows the KeyGen() + to_seed() pattern for generality; libraries that provide KeyGen_internal(ξ) or from_seed(ξ) constructors should use the seed-first pattern. A reimplementer who assembles sk = xwing_sk || ed25519_sk || mldsa_sk_expanded produces a 6496-byte secret key (2432 + 32 + 4032) — it will not parse as a valid identity secret key (2496-byte size check fails). The check in IdentitySecretKey::from_bytes() catches this immediately, so the failure is not silent. However, if a reimplementer writes their own construction without a size check, they may store the wrong form and only discover the mismatch at signing time.
XWing.KeyGen() uses X25519-first key layout — LO diverges from draft-09: The X-Wing secret key returned by XWing.KeyGen() is stored as sk_X (32 bytes) ‖ dk_M (2400 bytes) — X25519 component first, ML-KEM-768 expanded decapsulation key second. IETF draft-connolly-cfrg-xwing-kem-09 specifies the opposite order: dk_M ‖ sk_X. A reimplementer who follows draft-09's field ordering produces an incompatible 2432-byte secret key layout — ExtractXWingPrivate extracts the wrong bytes, decapsulation silently derives a wrong shared secret, and AEAD fails with no diagnostic pointing to the key-layout swap. The public key ordering is the same in both LO and draft-09 (X25519 public key first, ML-KEM-768 public key second). See §8.1 and §8.5 for the complete layout specification.
ML-KEM-768 KeyGen requires two independent 32-byte entropy draws: XWing.KeyGen() internally calls ML-KEM.KeyGen (FIPS 203 §7.1), which requires two independently-random 32-byte seeds d and z. The soliton reference implementation draws d and z independently from the OS CSPRNG (two separate getrandom calls). A reimplementer who derives both from a single seed (e.g., d = HKDF(seed, "d", 32), z = HKDF(seed, "z", 32)) produces a non-conforming key — the ML-KEM security proof requires that d and z are independently uniform; deriving both from a common secret violates this requirement and may weaken the IND-CCA2 security of the KEM. There is no structural or size-based signal that detects this mistake: the resulting keypair is the correct size, and encapsulation/decapsulation succeed normally. A conformance test MUST verify that d and z are generated by separate CSPRNG calls, not derived from a shared value.
Cross-library seed extraction is NOT portable via API name: to_seed(), signing_key.to_bytes()[0..32], seed(), private_key_bytes(), and similar method names are NOT equivalent across ML-DSA library implementations. In the Rust ml-dsa crate, to_seed() returns exactly ξ (the 32 bytes passed to ML-DSA.KeyGen_internal). In other libraries (BouncyCastle, Go's circl, liboqs), to_bytes()[0..32] may return the first bytes of the expanded signing key or a different internal representation — not the seed. The only portable cross-library verification: extract the candidate 32 bytes, call ML-DSA.KeyGen_internal(candidate) (FIPS 204 §6.1), and compare the resulting public key against the known public key. If they match, the candidate is ξ. Any candidate that does not round-trip to the known public key is not the seed, regardless of the API name used to extract it.
If your ML-DSA library does not expose ξ after KeyGen() at all (e.g., liboqs, BouncyCastle, and PQClean C bindings expose only the expanded key form with no seed accessor): generate ξ = random_bytes(32) yourself from the OS CSPRNG, then call ML-DSA.KeyGen_internal(ξ) (FIPS 204 §6.1) directly to obtain the public key. This produces a valid keypair with ξ as the seed, bypassing the library's opaque KeyGen() entirely. See §8.5 for the two-level API pattern (KeyGen() vs KeyGen_internal(ξ)) and for what ML-DSA.KeyGen_internal MUST consume (no CSPRNG input — it is a pure deterministic function of ξ).
The hex-encoded fingerprint is for display and user-facing comparison (§9). All wire-format fields (sender_ik_fingerprint, recipient_ik_fingerprint, local_fp, remote_fp) use the raw 32-byte SHA3-256 digest, not the 64-character hex string.
2.2 Component Extraction
function ExtractX25519Public(pk): return pk[0..32]
function ExtractMLKEMPublic(pk): return pk[32..1216]
function ExtractEd25519Public(pk): return pk[1216..1248]
function ExtractMLDSAPublic(pk): return pk[1248..3200]
function ExtractXWingPublic(pk): return pk[0..1216]
function ExtractX25519Private(sk): return sk[0..32]
function ExtractXWingPrivate(sk): return sk[0..2432] // sk_X(32) || dk_M(2400): X25519 scalar + ML-KEM-768 NTT-domain expanded decapsulation key — see §8.5
function ExtractEd25519Private(sk): return sk[2432..2464] // 32-byte RFC 8032 seed s (RFC 8032 §5.1.5) — the raw random seed.
// NOT the SHA-512 hash of the seed, NOT the clamped scalar,
// NOT the 64-byte seed||public_key form (Go/libsodium default).
// ed25519_dalek::SigningKey::from_bytes() takes this exact form.
function ExtractMLDSAPrivate(sk): return sk[2464..] // 32 bytes (seed, NOT the 4032-byte expanded signing key (FIPS 204 §7.2, ML-DSA-65 sigKeySize) — see §8.5)
ML-DSA secret key is a 32-byte seed, not the expanded form: ExtractMLDSAPrivate returns a 32-byte seed (ξ), not the 4032 (FIPS 204 §7.2, ML-DSA-65 sigKeySize)-byte expanded signing key. The full expanded signing key is deterministically re-derived via ML-DSA.KeyGen_internal(ξ) (FIPS 204 §6.1) at signing time (§8.5). A reimplementer who stores the full expanded form produces a 6496-byte secret key (2432 + 32 + 4032) that is incompatible with soliton's 2496-byte layout (2432 + 32 + 32). ExtractMLDSAPublic(pk) returns the standard 1952-byte FIPS 204 pkEncode public key — no analogous storage divergence exists on the public side.
ML-KEM-768 sub-key sizes within X-Wing: The X-Wing components extracted by ExtractMLKEMPublic(pk) and ExtractXWingPrivate(sk) have fixed sub-structure (§8.1, §8.5): ML-KEM-768 public key (ek_PKE) = 1184 bytes (bytes 32-1215 of the X-Wing public key); ML-KEM-768 expanded secret key (dk_M) = 2400 bytes (bytes 32-2431 of the X-Wing secret key); ML-KEM-768 ciphertext = 1088 bytes (bytes 32-1119 of the X-Wing ciphertext). A reimplementer hard-coding the wrong ML-KEM-768 sub-key sizes (e.g., 1184 bytes for the secret key, which is the public key size) produces decapsulation keys that fail silently at AEAD — see §8.5 for the full dk_M field layout.
ML-KEM stores the full 2400-byte expanded decapsulation key; ML-DSA stores only the 32-byte seed: §2.1 explains that ML-DSA stores only ξ (32 bytes) and re-expands at sign time via ML-DSA.KeyGen_internal(ξ). A reimplementer might apply the same reasoning to ML-KEM — storing only a seed and re-deriving at decapsulation time. This does NOT work: FIPS 203 does not define a standard ML-KEM.KeyGen_internal(seed) equivalent that produces a deterministic decapsulation key from a single 32-byte seed in the way FIPS 204 §6.1 defines KeyGen_internal(ξ) for ML-DSA. The ML-KEM.KeyGen function takes two independent 32-byte values d and z (§2.1 above), and the expanded 2400-byte decapsulation key embeds both in expanded form (§8.5). There is no FIPS 203 pathway to regenerate the same 2400-byte dk_M from a shorter seed without storing d, z, and the expanded state separately — which is larger than the 2400-byte key itself. soliton stores the full dk_M (2400 bytes) in ExtractXWingPrivate(sk). A reimplementer who stores only a seed produces a layout-incompatible secret key.
IdentitySecretKey::from_bytes zeroizes the input buffer on the error path: from_bytes wraps the input in Zeroizing immediately on entry, so if InvalidLength is returned (wrong-size input), the caller's buffer is zeroed before the error propagates. This is a side-effect of the Rust Zeroizing wrapper — not a documented caller contract — but reimplementers and binding authors should be aware that a rejected-size buffer is zeroed. Callers who read the input back after a failed from_bytes call will find it zero. This side-effect does not apply to IdentityPublicKey::from_bytes (public keys are not secret; no zeroization on error).
Lazy validation: IdentityPublicKey::from_bytes() validates only the total size (3200 bytes). It does not parse or validate the X-Wing, Ed25519, or ML-DSA sub-key structures — invalid sub-key bytes are accepted at construction and produce errors only at use time (Encaps, HybridVerify, etc.). For example, a 3200-byte all-zero input is accepted at construction; encapsulation fails at use time when ML-KEM rejects the zero key material during matrix expansion, and signature verification fails when Ed25519 rejects the all-zero point as a non-canonical encoding. This is intentional: sub-key validation requires algorithm-specific parsing (ML-KEM coefficient range checks, Ed25519 point decompression, ML-DSA matrix expansion), which is expensive and duplicated by the operations themselves. Reimplementers MUST NOT assume that a successfully constructed IdentityPublicKey contains valid sub-keys. The same applies to IdentitySecretKey::from_bytes() (validates total size only).
Security note — identity key compromise: The identity secret key contains independent components: an X-Wing secret key (for KEM) and a dedicated Ed25519 secret key (for signing). A compromise of sk_IK yields both KEM decapsulation and signature forgery capability. The X25519 scalar within X-Wing is used solely for KEM; it plays no role in signing.
2.3 X-Wing Operations
Encapsulation and decapsulation use only the X-Wing components (X25519 + ML-KEM-768). The ML-DSA component is not involved.
function Encaps(lo_pk):
xwing_pk = ExtractXWingPublic(lo_pk)
return XWing.Encaps(xwing_pk)
function Decaps(lo_sk, ciphertext):
xwing_sk = ExtractXWingPrivate(lo_sk)
return XWing.Decaps(xwing_sk, ciphertext)
// Note: the X-Wing combiner (§8.2) requires pk_X — the decapsulator's own
// X25519 public key — which is re-derived from sk_X on every call as
// X25519(sk_X, G). It is NOT taken from the ciphertext. See §8.2.
3. Hybrid Signatures
All signatures in LO use Ed25519 + ML-DSA-65 in parallel. A signature is valid only if both components verify.
3.1 Signing
function HybridSign(lo_sk, message):
ed25519_sk = ExtractEd25519Private(lo_sk)
mldsa_sk = ExtractMLDSAPrivate(lo_sk)
sig_classical = Ed25519.Sign(ed25519_sk, message) // 64 bytes
sig_pqc = MLDSA.Sign(mldsa_sk, message) // 3309 bytes, hedged mode
return sig_classical || sig_pqc // 3373 bytes total
Domain labels are applied by callers, not inside HybridSign: The message parameter passed to HybridSign(lo_sk, message) MUST already contain any domain-separation label. HybridSign performs no label prepending, wrapping, or modification of its own — it signs the raw bytes as provided. Domain labels (e.g., "lo-spk-sig-v1", "lo-kex-init-sig-v1") are concatenated at the call site before invoking HybridSign (examples: §5.3, §5.4 Step 6). A reimplementer who embeds label handling inside HybridSign produces signatures over different bytes than the call-site concatenation: every signature over a labeled message would silently double-apply the label, making all such signatures incompatible with conforming implementations. Concretely: HybridSign(sk, "lo-spk-sig-v1" ‖ payload) is correct; HybridSign(sk, payload) where HybridSign internally prepends "lo-spk-sig-v1" is incorrect.
Byte layout: The 3373-byte composite signature is a raw concatenation with no length prefixes, delimiters, or type markers. Ed25519 occupies bytes 0-63 (fixed 64 bytes per RFC 8032), ML-DSA-65 occupies bytes 64-3372 (fixed 3309 bytes per FIPS 204). A reimplementer who adds length prefixes or uses variable-length Ed25519 encodings (some libraries return r || s || recovery_id) produces incompatible signatures. Split at byte offset 64, unconditionally.
HybridSign output is non-deterministic — do NOT compare two signatures byte-for-byte: Two calls to HybridSign(sk, same_message) produce the same bytes 0-63 (Ed25519 is deterministic per RFC 8032), but always different bytes 64-3372 (ML-DSA-65 uses hedged signing with fresh 32-byte randomness on each call). Byte-equality comparison of two HybridSign outputs is therefore always false for bytes 64-3372, even when both signatures are valid over the same message with the same key. Callers MUST use HybridVerify to check validity — never byte comparison. Systems that cache a signature (e.g., an SPK bundle signature) and later re-sign to compare will always see a mismatch.
ML-DSA message is a single contiguous buffer: Sign_internal and Verify_internal receive the full message as a single flat byte string — not a multi-part or streaming input. In Rust, the ml-dsa crate exposes sign_internal as signing_key.sign_internal(&[message], &rnd) where the outer slice is a &[&[u8]] of parts that are absorbed sequentially into the internal SHA3 state; soliton always passes a one-element slice containing the complete message buffer. A reimplementer whose ML-DSA library takes a &[&[u8]] multi-part interface for signing MUST pass the whole message as a single part — splitting it across multiple parts produces a different internal SHA3 hash state, resulting in incompatible signatures. The same applies to Verify_internal: if the library exposes a multi-part interface for verification (as some do, e.g., liboqs, BouncyCastle), the message MUST be passed as a single part. The ml-dsa Rust crate's verify path uses a flat &[u8], but other libraries may not.
ML-DSA internal API: Both signing and verification use the internal functions (Sign_internal / Verify_internal per FIPS 204 §6.2/§6.3) — the context string and domain separator defined in the public API (FIPS 204 §5.1) are not applied. This is intentional: soliton's domain separation is handled at the protocol level (per-context labels in §3.4, Appendix A). Signatures produced by soliton are not compatible with standalone FIPS 204 verifiers that apply the public API wrapper. Reimplementer warning: ML-DSA libraries outside Rust (liboqs, PQClean, BouncyCastle, Go's circl) often expose only the public API (ML-DSA.Sign / ML-DSA.Verify per FIPS 204 §5.1), which prepends a domain separator byte (0x00) and context string before calling the internal functions. Passing an empty context string to the public API is NOT equivalent to calling Sign_internal — the public API unconditionally prepends the 0x00 domain separator byte even with an empty context. Reimplementers must either access the internal functions directly or verify that their library provides a bypass for the public API's context/domain-separator wrapping. Using the public API produces signatures that are silently incompatible with soliton. rnd MUST be exactly 32 bytes: FIPS 204 §6.2 defines rnd as a 256-bit string (32 bytes). Libraries that accept rnd as a variable-length slice do not validate the size — passing 16 or 24 bytes silently weakens the hedging entropy without any error signal. Implementations MUST generate exactly 32 random bytes and MUST NOT pass a shorter or longer buffer. The reference implementation uses Zeroizing<[u8; 32]> and passes it as a 32-byte slice; binding-layer callers MUST ensure their random-byte generation produces exactly 32 bytes.
rnd MUST be freshly drawn from the OS CSPRNG for each individual Sign_internal call: Pre-generating rnd once and reusing it across multiple signing calls — or batching calls so multiple signatures share the same rnd — defeats the hedge entirely: repeated (message, rnd) pairs with constant rnd produce the same internal randomness on all calls, reducing hedged signing to deterministic signing and re-enabling the fault-injection attacks the hedge defends against. Each HybridSign call MUST draw 32 fresh bytes independently.
Hedged mode rationale: Hedged signing combines deterministic signing with 32 bytes of fresh randomness (rnd parameter), preventing fault-injection attacks that exploit deterministic nonce generation to extract the signing key. The rnd buffer is ephemeral secret material and MUST be zeroized immediately after Sign_internal returns — leaking it reduces hedged signing to deterministic signing, re-enabling the fault-injection attacks the hedge defends against. In Rust, wrapping in Zeroizing<[u8; 32]> handles this automatically; in C/Go/Python, the caller must explicitly zeroize the buffer.
Transient 4032-byte SigningKey zeroization: Every Sign_internal call re-expands the 32-byte seed ξ into the full 4032-byte signing key (s₁, s₂, t₀, t₁ polynomials — §8.5). This transient 4032-byte signing key MUST be zeroized before deallocation. In Rust, the ml-dsa crate's SigningKey implements ZeroizeOnDrop — the expanded key is automatically zeroized when the local variable goes out of scope after Sign_internal returns. In C/Go/Python implementations that call ML-DSA at a lower level, the caller MUST explicitly call memset_s (C) or equivalent on the 4032-byte signing key buffer before freeing it. Note the asymmetry with rnd above: rnd (32 bytes) is documented explicitly because it is ephemeral entropy whose leakage restores a broken security property; the 4032-byte SigningKey obligation is handled automatically in Rust but is equally important for C/Go/Python reimplementers — a leaked expanded signing key permits arbitrary ML-DSA-65 forgery.
Two secrets per Sign_internal call — summary for non-RAII implementations: Each call to HybridSign produces exactly two secret temporaries that must be zeroized: (1) the 32-byte hedged rnd buffer (described above — leaking it re-enables fault-injection attacks); and (2) the 4032-byte expanded ML-DSA-65 signing key (described above — leaking it permits arbitrary forgery). Rust's ZeroizeOnDrop handles both automatically; C/Go/Python callers must zeroize both explicitly at each call site.
sign_internal vs. verify_internal API asymmetry in the ml-dsa crate: In the ml-dsa Rust crate, sign_internal takes a &[&[u8]] (multi-part message slice), while verify_internal takes a flat &[u8]. Soliton always passes a one-element slice to sign_internal (signing_key.sign_internal(&[full_message], &rnd)), so the API difference is transparent at the call site. Reimplementers using a different ML-DSA library must confirm whether their library's Sign_internal and Verify_internal both accept a single flat buffer or use multi-part interfaces — and ensure both call sites pass the full message as a single contiguous input. This asymmetry does not affect correctness when both are used with a single part: SHA3's sponge construction absorbs input sequentially, so H(a) equals H_sponge.absorb(a).finalize() regardless of how the buffer is chunked. The sponge invariant means that a multi-part signing interface receiving a single part produces the same internal hash as a flat interface receiving the same bytes — there is no chunk-boundary hazard when exactly one part is used.
3.2 Verification
function HybridVerify(lo_pk, message, signature):
if len(signature) != 3373:
raise InvalidLength // Must check before slicing — a short input causes
// signature[64..3373] to panic or read out-of-bounds.
// Returns InvalidLength (not VerificationFailed) because
// the error is on a caller-supplied parameter size, not
// a cryptographic failure.
//
// Typed-language note: a reimplementation that uses a
// `HybridSignature` wrapper type enforcing the 3373-byte
// invariant at construction time (e.g., via `from_bytes`)
// satisfies this check at the type level — `hybrid_verify`
// itself need not repeat it. An auditor comparing the typed
// implementation against this pseudocode should treat the
// type-constructor check as conformant with the inline guard
// shown here.
ed25519_pk = ExtractEd25519Public(lo_pk)
mldsa_pk = ExtractMLDSAPublic(lo_pk)
sig_classical = signature[0..64]
sig_pqc = signature[64..3373]
ok_classical = Ed25519.Verify(ed25519_pk, message, sig_classical)
ok_pqc = MLDSA.Verify_internal(mldsa_pk, message, sig_pqc)
// BOTH must pass. Both verifications are evaluated eagerly (no short-circuit).
// The AND combination MUST be constant-time (e.g., subtle::Choice or equivalent
// bitwise AND) — a naive boolean && or branch on ok_classical leaks which
// component failed via timing, enabling targeted forgery of only the weaker component.
// Eagerness and constant-time AND are JOINT requirements — either alone is insufficient:
// - Eager evaluation without CT AND: both calls run, but a branch on the combined result
// still leaks whether the result is true or false via timing.
// - CT AND without eager evaluation: the bitwise AND is constant-time, but only computing
// ok_pqc when ok_classical is true leaks that Ed25519 passed via a timing side-channel.
// The correct implementation evaluates BOTH verify calls unconditionally, then combines
// the results with a bitwise AND (or equivalent constant-time operation) and branches only
// on the combined boolean — not on either individual result.
return ok_classical AND ok_pqc
Signature size validation: Before slicing, callers MUST verify len(signature) == 3373. Passing a shorter input causes the slice signature[64..3373] to panic or read out-of-bounds (language-dependent). More critically: some ML-DSA libraries return an error (not false) when given a wrong-size input. If that error propagates as a distinct failure mode rather than being collapsed to false before the AND combination, it breaks the constant-time AND requirement — the caller can distinguish "wrong size" from "right size but invalid" via timing or exception, leaking a distinguishing oracle. Any library error on a bad-size ML-DSA input MUST be treated as false for the AND combination, not propagated as a distinct exception.
HybridVerify returns InvalidLength for a wrong-size composite signature: When len(signature) ≠ 3373, HybridVerify returns InvalidLength before any slicing or cryptographic operation. This differs from the sub-component failure mappings (Ed25519 key import → VerificationFailed, ML-DSA signature decode → VerificationFailed), which fire during the verification operation itself. The top-level composite size check fires before any verification-layer operation begins and returns InvalidLength — the error is not oracle-exploitable because the attacker crafted the input and already knows whether its length is correct. A reimplementer who collapses this to VerificationFailed for consistency produces a divergent but still secure result; however, binding authors should document which error to expect.
Ed25519 verification strictness: Ed25519.Verify MUST use strict verification per RFC 8032 §5.1.7, rejecting non-canonical S values (S ≥ L), small-order public keys, and non-canonical point encodings. ZIP-215 permissive verification (as used by crypto/ed25519 in Go and some other libraries) is NOT compatible — it accepts signatures that soliton rejects, producing silent interoperability failures on HybridVerify. The implementation uses verify_strict() from ed25519-dalek. Reimplementers MUST verify their Ed25519 library defaults to strict mode or explicitly select it.
Caution — "strict mode" varies by library: Some Ed25519 libraries advertise a "strict" or "batch-compatible" mode that checks only S-canonicity (S < L, i.e., the scalar is in the range [0, ℓ−1]) but does NOT reject small-order public keys. Curve25519 has cofactor 8 and eight torsion points (points of order dividing 8); a small-order public key causes BasePoint × s × pk to produce a predictable output for any signature, allowing an attacker who controls pk to forge a valid signature under any private key. ed25519-dalek ≥ 1.0 (used by soliton) rejects all eight torsion points via VerifyingKey::from_bytes. Reimplementers using other libraries MUST explicitly verify that their "strict" mode includes small-order-key rejection — S-canonicity alone is insufficient.
Ed25519 key import failure during HybridVerify maps to VerificationFailed, not InvalidData: ExtractEd25519Public slices bytes 1216..1248 from the public key and passes them to the Ed25519 library's key import function (VerifyingKey::from_bytes in ed25519-dalek). If those 32 bytes are not a valid compressed Edwards point, the import fails. HybridVerify collapses this failure to VerificationFailed, not InvalidData — the key bytes are structurally the right length and format (the public key size was already validated by IdentityPublicKey::from_bytes), so the failure is a verification-layer rejection, not a parsing failure. A reimplementer whose Ed25519 library propagates import failures as exceptions must catch them before the AND combination and treat them identically to a verification failure. A library that silently accepts invalid compressed points at import and produces incorrect verification results (rather than an error) would diverge silently: an invalid Ed25519 sub-key would appear to "verify" as false when it should have failed on import, which coincidentally produces the same combined VerificationFailed result — but through the wrong code path, and only for signatures that happen to fail the boolean check. A reimplementer must confirm their Ed25519 library errors on invalid point encoding rather than silently accepting it.
ML-DSA public key import failure during HybridVerify maps to VerificationFailed, not InvalidData: ExtractMLDSAPublic slices bytes 1248..3200 from the public key and passes them to the ML-DSA library's key import function. If those 1952 bytes are structurally invalid (e.g., polynomial coefficients outside [0, q−1] rejected by pkDecode, FIPS 204 §7.1), the import fails. HybridVerify collapses this failure to VerificationFailed, not InvalidData — identical rationale to the Ed25519 case above (structurally valid-length bytes, verification-layer rejection). A reimplementer whose ML-DSA library propagates import failures as distinct exceptions must catch them before the AND combination and treat them as false. Libraries that silently accept out-of-range coefficients at import and produce wrong verification results diverge silently in the same way as the Ed25519 case: a bad public key appears to "verify" as false, which yields the correct final result through the wrong code path. ML-DSA infallible decode — third case: soliton's own ML-DSA implementation (ml-dsa crate) accepts any 1952-byte input as a valid key without checking coefficients at import — VerifyingKey::from_bytes is infallible for correctly-sized inputs. Out-of-range coefficients are not rejected at import; they produce wrong polynomial arithmetic in verify_internal, which returns false → VerificationFailed. This is a third behavior not covered by the spec's "reject vs. normalize" binary: accept-and-produce-wrong-result. For soliton's purposes, this is safe (wrong coefficients → always-false verification → correct rejection of the forged signature) but a reimplementer who reads "MUST confirm their ML-DSA library rejects invalid coefficient encodings" may incorrectly conclude that rejection-at-import is required. It is not — any behavior that produces VerificationFailed for a key with out-of-range coefficients (import error, normalization-then-wrong-result, or implicit-wrong-result) satisfies the security requirement. The concern is libraries that normalize out-of-range coefficients modulo q at import and then produce wrong-but-consistent verification results — the re-encode cross-check below catches these. Note: unlike Ed25519, where invalid points produce errors at import, some ML-DSA libraries reduce out-of-range coefficients modulo q silently on import — producing a different public key than the original bytes represent. A public key that round-trips through such a library's import→export cycle differs byte-for-byte from the original, causing HybridVerify to fail even for an authentic signature (the verified message is computed against the original bytes, but the coefficients used for verification differ after normalization). Reimplementers importing ML-DSA public keys from external libraries should apply the re-encode cross-check described in §8.5.
ML-DSA signature structural decode failure maps to VerificationFailed, not InvalidData: A 3309-byte ML-DSA signature with polynomial coefficients outside [0, q−1] will pass the size check (len(sig_pqc) == 3309) but fail at the ML-DSA library's signature decode step. In the ml-dsa crate, Signature::decode() returns None for such inputs. soliton maps this None to VerificationFailed — not InvalidData. The rationale: the size is correct; the structural failure is a property of the signature bytes themselves, not of the API call. Reimplementers whose ML-DSA library exposes a two-step API (decode then verify) must catch the decode failure explicitly and map it to VerificationFailed. If the decode failure propagates as a distinct exception or error type, it breaks the constant-time AND requirement (the caller can distinguish "decode failed" from "verify returned false" via exception type, even if the combined result is the same). The correct mapping: any ML-DSA decode failure → treat as ok_pqc = false → combined VerificationFailed. This is a third failure mode not covered by the wrong-size path (which fails at slicing before decode) or the public-key path (import failure) — it requires a separate catch in reimplementations.
3.3 Security Properties
- Classical: Ed25519 provides 128-bit classical security (RFC 8032).
- Post-quantum: ML-DSA-65 provides NIST Level 3.
- Hybrid guarantee: Forgery requires breaking both simultaneously.
- EUF-CMA: The parallel composition ("both must verify") is EUF-CMA secure if either component is (Bindel et al., PQCrypto 2017 — see Appendix D, "Hybrid Constructions").
3.4 Where Signatures Are Used
Signatures are used in two contexts in v1:
- Pre-key bundle signing (§5.3): Identity key signs the signed pre-key's public key material.
- Session initiation signing (§5.4 Step 6): Alice's identity key signs the encoded SessionInit, proving to Bob that the session was initiated by the holder of sk_IK_A.
Signatures are NOT used for:
- Server-side authentication (KEM-based, §4).
- Message encryption (symmetric, §7).
- Ratchet key agreement (KEM-based, §6).
Header authentication without signatures: The §3.4 "not used for message encryption" note naturally prompts the question of how ratchet message headers are protected against tampering. The answer is AEAD AAD binding (§7.3): each message's ciphertext authenticates the full encoded ratchet header (sender_fp || recipient_fp || header_bytes) as additional associated data. A tampered header (e.g., modified kem_ct or wrong n) causes AEAD authentication to fail at decryption. Signatures are therefore unnecessary for per-message header integrity — the AEAD tag provides it.
4. KEM-Based Authentication
4.1 Purpose
Proves possession of the private key corresponding to a claimed public identity. Only the legitimate key holder can decapsulate.
4.2 Protocol
Client Server
| |
| --- lo_pk (3200 B) -----------> |
| |
| xwing_pk = ExtractXWingPublic(lo_pk)
| (ct, ss) = XWing.Encaps(xwing_pk)|
| token = HMAC-SHA3-256(ss, "lo-auth-v1")
| // ss zeroized immediately
| |
| <--- ct (X-Wing ciphertext) --- |
| |
| ss = XWing.Decaps(xwing_sk, ct) |
| proof = HMAC-SHA3-256(ss, "lo-auth-v1") |
| // ss zeroized immediately |
| |
| --- proof (32 bytes) ----------> |
| |
| constant_time_eq(proof, token) |
| // token zeroized after verify |
| |
| <--- READY or ERROR ----------- |
The three protocol steps correspond directly to the three CAPI entry points: the server's encapsulate-and-token step is soliton_auth_challenge; the client's decapsulate-and-proof step is soliton_auth_respond; the server's comparison step is soliton_auth_verify. Each CAPI function implements exactly one arrow in the diagram above — auth_challenge issues the ciphertext, auth_respond consumes it and returns the proof, auth_verify checks the proof against the stored token.
The X-Wing ciphertext ct is exactly 1120 bytes (32 bytes ct_X + 1088 bytes ct_M — see Appendix C). The proof value proof / token is 32 bytes (HMAC-SHA3-256 output).
General HMAC encoding rule — raw data, no length prefix: Throughout this protocol, HMAC data arguments are passed as raw bytes with no length prefix. This is the opposite of HKDF info fields (§5.4, §6.12), which use len(x) || x length-prefixed encoding. The distinction: HKDF info uses length prefixes because it concatenates multiple variable-length fields into a single domain-separation string; HMAC data is always a single, fixed-purpose input (a domain label or a counter byte) where no length prefix is needed. A reimplementer who applies the HKDF len(x) || x convention to HMAC data arguments produces a different MAC output with no error signal.
Convention: HMAC-SHA3-256(key, message) — the shared secret ss is the HMAC key, and the domain label "lo-auth-v1" is the message. ss is the key because it is the high-entropy secret material; the label is the data/domain separator. HMAC's security requires the key to be the secret — placing ss as the data argument and the label as the key would produce a MAC keyed by a public constant, which is trivially forgeable by anyone who knows the label. The label is 10 raw ASCII bytes with no length prefix — unlike the HKDF info fields in §5.4 which use len(x) || x format, HMAC in §4.2 passes the label directly as the HMAC data argument. C: use strlen("lo-auth-v1") (= 10), not sizeof("lo-auth-v1") (= 11) — sizeof includes the NUL terminator, producing an 11-byte input that yields a silently different HMAC token. A reimplementer who applies the §5.4 convention here would prepend a 2-byte BE length (0x00 0x0a) before the label, producing a different token.
4.3 Security Properties
- Key possession proof: Only private key holder can produce correct HMAC.
- Replay resistance (intra-connection): Fresh randomness per encapsulation prevents stale-proof replay within the same connection — an old
(ct, proof)pair cannot be reused with a newctchallenge because the proof HMAC is bound to the specificssfrom that encapsulation. Cross-connection replay is not prevented by fresh randomness alone — an adversary who captures a valid(ct, proof)pair can replay it against a different server instance that issues the samect(e.g., via a replay of the encapsulation step). Cross-connection replay resistance requires the transport-layer session binding documented in §4.4; without it, the 30-second timeout only limits the replay window. - Post-quantum: X-Wing hybrid construction.
- No signature required: Pure KEM paradigm.
4.4 Requirements
- Server MUST validate the client's
lo_pkis exactly 3200 bytes before beginning authentication. Accepting a short or oversized public key and then slicing into it forExtractXWingPubliccauses out-of-bounds access or reads from the wrong offset, producing a pseudorandom shared secret and silent HMAC mismatch — indistinguishable from an authentication failure. Length validation MUST precede encapsulation. A server that defers this check toEncapswill incur the full cost of an X-Wing KeyGen before discovering the malformed key — a pre-association DoS vector: an unauthenticated client can force repeated expensive KeyGen operations by sending malformed keys. - Wrong-length
lo_pkMUST collapse to a generic authentication failure response: Returning a distinguishable error code for a wrong-length vs. correct-length key creates a length-probing oracle — an adversary can probe response codes to confirm whether a key is the expected size before committing to a full authentication attempt. Any authentication failure (wrong-length key, malformed key, correct-length key with bad cryptographic content) MUST produce the same externally observable outcome: generic authentication failure or connection close. The length check MUST still be enforced internally (to prevent the out-of-bounds access described above), but the error response to the client MUST NOT distinguish length failures from other authentication failures. - Server MUST use fresh randomness per encapsulation. Each
(ct, token)pair MUST be delivered to the client at most once — caching and redelivering a previously generated ciphertext is forbidden even if a new encapsulation would produce the same entropy. Redelivering a pair gives an adversary an additional observation opportunity beyond the 30-second timeout window: an attacker who captures a replayed pair can attempt offline HMAC forgery against the samess. Fresh randomness prevents entropy reuse, but delivery-uniqueness is a separate, additional requirement. - HMAC comparison MUST be constant-time (
subtle::ConstantTimeEq). The comparison uses the full 32-byte HMAC-SHA3-256 output — no truncation. Implementations using a "HMAC-with-length" API parameterized on output length MUST request 32 bytes and compare all 32 bytes. A truncated comparison (e.g., 16 bytes) weakens forgery resistance from 256-bit to 128-bit and produces an incompatible proof token that fails on conforming servers. - Shared secret MUST be zeroized immediately after proof computation.
- Auth token (proof HMAC) MUST be zeroized by the server immediately after the constant-time comparison. A server that retains the token in a session cache (e.g., for re-authentication within the 30-second window) enables token replay — an attacker who observes the token can resubmit it on the same connection before expiry. The 30-second timeout bounds but does not eliminate this window. Single-use: one comparison, then zeroize.
- Label
"lo-auth-v1"is a domain separator preventing cross-protocol attacks. - Transport-layer session binding: The proof token binds no server identity, timestamp, or connection identifier — replay resistance depends entirely on the transport layer binding the issued ciphertext to the specific connection on which it was issued. The server MUST reject a proof token received on any connection other than the one on which it issued the ciphertext. A token that escapes its connection context (e.g., via session hijacking or protocol downgrade) is replayable against any server that would issue the same ciphertext. The 30-second timeout bounds the window but does not replace the connection-binding requirement — without it, the timeout merely limits the replay window rather than preventing replay entirely.
4.5 Error Variants
| Function | Error | Condition |
|---|---|---|
soliton_auth_challenge (CAPI only) |
InvalidLength |
lo_pk not exactly 3200 bytes — Rust API only: the Rust auth_challenge(client_pk: &IdentityPublicKey) takes a typed reference; the size is enforced by the type system and InvalidLength cannot be returned. This guard exists only in the CAPI wrapper. |
soliton_auth_respond (CAPI only) |
InvalidLength |
ct not exactly 1120 bytes — Rust API only: the Rust auth_respond(ct: &xwing::Ciphertext) takes a typed reference; the size is enforced at construction by the xwing::Ciphertext type and InvalidLength cannot be returned. This guard exists only in the CAPI wrapper. |
soliton_auth_verify (CAPI only) |
InvalidLength |
expected_token not exactly 32 bytes — checked first (before auth_proof); same compile-time note as below applies. |
soliton_auth_verify (CAPI only) |
InvalidLength |
auth_proof not exactly 32 bytes — Rust API only: the Rust auth_verify(expected: &[u8; 32], proof: &[u8; 32]) -> bool takes fixed-size array references; wrong-size inputs are rejected at compile time by the type system and InvalidLength cannot be returned. These guards exist only in the CAPI wrapper (soliton_auth_verify), which receives raw pointers and lengths. |
soliton_auth_verify (CAPI only) |
VerificationFailed |
Constant-time comparison failed (proof ≠ token) — Rust API only: the Rust auth_verify(expected: &[u8; 32], proof: &[u8; 32]) -> bool returns false on mismatch; VerificationFailed is returned only by the CAPI wrapper. |
External error collapsing requirement (see also §4.4): Callers MUST map all LO-Auth failures — InvalidLength from any step, VerificationFailed from auth_verify — to the same external authentication-failure response (e.g., connection close or generic error code). Returning a distinguishable error per step (e.g., "wrong key size" vs. "HMAC mismatch") enables an oracle: an attacker can probe which step failed and thereby determine whether the submitted key is the correct length (step 1 passes), whether the ciphertext was accepted (step 2 passes), and whether the HMAC matched (step 3 passes) — progressively confirming each layer of the authentication independently. All failures must be indistinguishable externally, regardless of which step triggered them.
5. LO-KEX: KEM-Based Key Agreement
5.1 Goals
- Mutual authentication (both cryptographic: recipient via KEM; initiator via HybridSign over SessionInit — see §5.6).
- Forward secrecy via pre-key rotation and single-use OPKs.
- Post-quantum security via X-Wing.
- Offline initiation via pre-keys.
- Multi-key session binding (session key requires compromise of both IK and SPK).
5.2 Key Material
| Key | Type | Size (pk) | Lifetime | Purpose |
|---|---|---|---|---|
| Identity Key (IK) | LO composite | 3200 B | Long-term | Auth, signing |
| Signed Pre-Key (SPK) | X-Wing | 1216 B | ~weekly | Session initiation |
| One-Time Pre-Keys (OPK) | X-Wing | 1216 B | Single use | Enhanced forward secrecy |
Pre-keys are X-Wing only (no ML-DSA) because they need KEM, not signing.
OPK secret key storage format: OPK secret keys use the same expanded 2432-byte X-Wing secret key format as IK and SPK (§8.5): 32-byte X25519 scalar || 2400-byte ML-KEM-768 decapsulation key (NTT-domain). The table above shows public key size (1216 bytes); the stored secret key is 2432 bytes. Storing only the 32-byte X25519 scalar seed and re-deriving the ML-KEM portion at use is NOT supported — soliton stores the expanded form directly.
SPK private key retention after rotation: Rotating to a new SPK does NOT immediately delete the old SPK private key. The old private key MUST be retained for 30 days after rotation (Appendix B) to allow in-flight sessions that encapsulated to the old SPK to complete. Deleting the private key at rotation time causes silent InvalidData rejections for any SessionInit that arrived after rotation but was encapsulated to the pre-rotation SPK. After the 30-day window, the private key MUST be deleted — retaining it beyond that date extends the forward-secrecy exposure window. See §5.5 Step 4 and §10.2 for the deletion obligation and its security implications.
5.3 Pre-Key Bundle
Published to the user's home DM relay:
PreKeyBundle = {
IK_pub: LO composite public key (3200 bytes)
crypto_version: "lo-crypto-v1"
SPK_pub: X-Wing public key (1216 bytes)
SPK_id: uint32
SPK_sig: Hybrid signature (3373 bytes)
OPK_pub: X-Wing public key (1216 bytes) [optional]
OPK_id: uint32 [optional]
}
OPK_pub and OPK_id must be both present or both absent.
SPK_id uniqueness obligation: SPK_id MUST be unique per server identity within the 30-day SPK retention window (§10.2). If a new SPK is generated with the same SPK_id as a recently deleted SPK that is still in its grace period, receive_session will silently retrieve the wrong secret key for that ID, producing AeadFailed with no diagnostic. A monotonic counter (incrementing on each SPK rotation) satisfies this constraint. Random 32-bit IDs are also acceptable given the collision probability over typical rotation schedules (~3 × 10⁻⁸ for a 30-day window with weekly rotation). Relay implementations MUST NOT reuse an SPK_id until the previous SPK with that ID has been fully deleted from the grace-period store. Note that SPK_id is a server-assigned opaque identifier — the reference implementation does not specify or enforce an allocation policy; uniqueness is a server-side obligation.
Wire format: The pre-key bundle is a transport-layer struct — soliton does not define a canonical binary encoding for it (unlike SessionInit, which has encode_session_init in §7.4). The transport protocol serializes the bundle for relay storage and retrieval. Field ordering and encoding are protocol-spec concerns. However, the following constraints apply regardless of wire format:
encode_prekey_bundle(b) =
len(b.crypto_version) || b.crypto_version // UTF-8, 2-byte BE len
|| b.IK_pub // 3200 bytes (fixed, no length prefix)
|| b.SPK_pub // 1216 bytes (fixed, no length prefix)
|| big_endian_32(b.SPK_id)
|| b.SPK_sig // 3373 bytes (fixed, no length prefix)
|| if OPK present: 0x01 || b.OPK_pub || big_endian_32(b.OPK_id)
else: 0x00
Decoder strictness: A conforming decoder for encode_prekey_bundle MUST reject: (1) any has_opk byte other than 0x00 or 0x01 — values 0x02-0xFF are invalid and MUST return InvalidData; (2) any trailing bytes after the last field — accept only the exact length implied by has_opk. Compare with §7.4's explicit "Trailing bytes after the last field → InvalidData" rule for decode_session_init. A decoder that accepts has_opk = 0x02 as "OPK present" produces the same parsed output as has_opk = 0x01 but allows an attacker to craft bundles that pass decoding with non-canonical bytes, creating format-malleability.
This encoding is not used in any AAD or signature (SPK_sig covers only the raw SPK_pub, not the bundle). It is provided as a reference for interoperable relay implementations. Two relays using different bundle encodings will not cause cryptographic failure — the fields are parsed individually, not as a blob — but a canonical encoding simplifies relay interop testing. For federated relay-to-relay bundle exchange, this encoding SHOULD be adopted as the shared convention: while the encoding is advisory for soliton clients (which parse individual fields), relays exchanging bundles in raw-blob form must agree on a representation. Two relays with incompatible bundle encodings produce parsing failures at relay ingestion without any cryptographic failure — the error is silent from the client's perspective. If a relay-level bundle exchange protocol does not independently negotiate encoding, it SHOULD adopt encode_prekey_bundle as normative for that exchange.
SPK_sig is computed over the domain-separated SPK public key (raw concatenation is unambiguous):
SPK_sig = HybridSign(IK_sk, "lo-spk-sig-v1" ‖ SPK_pub)
Raw concatenation — no length prefixes. This is safe because both components are fixed-size: the label is exactly 13 bytes and SPK_pub is exactly 1216 bytes, so no length prefixes are needed for unambiguous parsing. A reimplementer who adds length prefixes "for safety" produces different signed bytes and breaks all SPK signature verification. SPK_pub is the verbatim 1216-byte output of XWing.KeyGen() — no clamping, masking, or normalization is applied to any component (X25519 or ML-KEM) between key generation and signing. The bytes signed and stored must be identical. Some X25519 libraries normalize the public key (clear bit 255, or apply RFC 7748 clamping to the scalar before computing the public point), producing a different 32-byte value than the raw keygen output. If a reimplementer signs the pre-normalization bytes but stores the post-normalization bytes (or vice versa), HybridVerify in §5.4 Step 1 silently fails. The fixed 13-byte label "lo-spk-sig-v1" is a domain separator that prevents cross-context signature reuse — if the identity key is later used to sign other payloads (e.g., profile data or future protocol extensions), signatures from one context cannot be replayed in another. The SPK_id, crypto_version, OPK_pub, and OPK_id are metadata that travel alongside the signed key, not part of the signed message. Omitting crypto_version from the signature is intentional — downgrade protection relies on the hard-fail version policy (§14.14), not on signature binding. Omitting OPK_pub and OPK_id is intentional — OPKs are generated and signed independently, and their presence or absence in a bundle does not affect the authenticity of the SPK. SPK_sig is entirely independent of OPK data: Bob can add or remove OPKs from a bundle without invalidating the SPK signature, and a reimplementer who includes OPK bytes in the SPK signed message produces SPK signatures that fail verification on any bundle where the OPK differs.
Rationale for label-only domain separation: The signed message is a fixed label + raw key bytes, with no variable-length metadata. This keeps the signature verifiable without any metadata parsing ambiguity — the verifier has the label (a compile-time constant), the raw SPK_pub (from the bundle), and the raw IK_pub (from identity lookup). If SPK_id or crypto_version were included in the signed message, both signer and verifier would need to agree on an encoding format for those fields — an unnecessary source of interop bugs.
5.4 Session Initiation (Alice → Bob)
Alice wants to DM Bob. She has Bob's identity key (from community context or out-of-band) and fetches his pre-key bundle from his home relay.
Step 1: Verify Pre-Key Bundle
function VerifyPreKeyBundle(bundle, known_bob_ik):
assert OPK fields are both present or both absent
// Structural co-presence check fires FIRST, before any cryptographic operation.
// Returns InvalidData (not BundleVerificationFailed) — tests format, not content.
assert bundle.IK_pub == known_bob_ik
assert bundle.crypto_version == "lo-crypto-v1"
assert HybridVerify(bundle.IK_pub, "lo-spk-sig-v1" ‖ bundle.SPK_pub, bundle.SPK_sig)
// Any assertion failure → abort, warn user
verify_bundle error collapse (anti-oracle): All non-structural verification failures — IK_pub mismatch, crypto_version mismatch, HybridVerify failure — return BundleVerificationFailed, not distinct error codes. A crypto_version mismatch returns BundleVerificationFailed, not UnsupportedVersion. Returning UnsupportedVersion for a version mismatch or VerificationFailed for a signature failure would let an attacker iteratively probe bundles to determine which specific field failed without possessing the correct keys — each distinct error response narrows the search space. The structural OPK co-presence check returns InvalidData (not BundleVerificationFailed) because it fires before any cryptographic operation and tests only format, not content. See §5.5 Step 1 for the parallel error-collapse analysis at the recipient side.
The type system enforces that initiate_session cannot be called with an unverified bundle; verify_bundle returns a VerifiedBundle newtype.
crypto_version maximum length: Parsers MUST reject any crypto_version field longer than 64 bytes with InvalidLength before performing the equality check. The 2-byte BE length prefix in encode_prekey_bundle can represent values up to 65,535 — a crafted bundle with a 65,535-byte version string consumes ~64 KiB before the equality check fires. Since "lo-crypto-v1" is 12 bytes, any field longer than 64 bytes is structurally impossible for a conforming version string, even accounting for hypothetical future versions. The CAPI enforces the broader decode_session_init input cap (64 KiB, §13.4), but a Rust reimplementer or binding author consuming the bundle fields individually MUST apply this length guard explicitly.
Step 2: Generate Ephemeral Key
(EK_pub, EK_sk) = XWing.KeyGen()
The keypair MUST be freshly generated from the OS CSPRNG for each initiate_session call. Reusing EK across sessions causes both sessions to share the same initial send_ratchet_sk — if EK_sk is compromised (e.g., via a side-channel during one session), every session initiated with that EK is also compromised at the initial ratchet epoch.
This ephemeral key serves as Alice's initial ratchet public key in LO-Ratchet (§6). Bob will encapsulate to it when performing the first KEM ratchet step upon replying. EK_sk must be preserved through Steps 3-7 and passed to RatchetState::init_alice (§5.5 / §13.5) as the initial send_ratchet_sk. Discarding EK_sk after constructing the SessionInit — e.g., freeing or zeroizing it once EK_pub has been extracted for the SessionInit struct — leaves Alice without the decapsulation key for Bob's first KEM ratchet step; decapsulation of Bob's first response silently fails (wrong epoch key → AeadFailed). EK_sk MUST NOT be used for any purpose other than this KEM decapsulation: using it for additional DH operations, separate KEMs, or signing creates cross-context key reuse that voids the forward-secrecy guarantee for OPK-less sessions. EK_sk is single-purpose — it decapsulates Bob's first KEM ratchet ciphertext and is then zeroized.
Step 3: KEM Encapsulations
// Encapsulate to Bob's identity key (authentication + defense-in-depth)
(ct_ik, ss_ik) = XWing.Encaps(ExtractXWingPublic(Bob.IK_pub))
// Encapsulate to Bob's signed pre-key (session binding)
(ct_spk, ss_spk) = XWing.Encaps(Bob.SPK_pub)
// Encapsulate to Bob's one-time pre-key (enhanced forward secrecy)
if Bob.OPK_pub is available:
(ct_opk, ss_opk) = XWing.Encaps(Bob.OPK_pub)
Each XWing.Encaps call requires independent fresh randomness: Each call draws its own 32-byte ML-KEM encapsulation coins from the OS CSPRNG (FIPS 203 §7.2 requires uniformly random per-call coins). Sharing or reusing the same 32-byte entropy across two or three calls produces correlated ciphertexts that violate IND-CCA2 for those encapsulations — decapsulation succeeds and the session key derives normally, so there is no error diagnostic. The three calls are entirely independent invocations of XWing.Encaps and MUST each draw fresh entropy.
Step 4: Derive Session Key
if OPK was used:
ikm = ss_ik || ss_spk || ss_opk // 96 bytes (3 × 32-byte X-Wing shared secrets)
else:
ikm = ss_ik || ss_spk // 64 bytes (2 × 32-byte X-Wing shared secrets)
info = "lo-kex-v1" // raw 9-byte prefix (not length-prefixed)
|| len(crypto_version) || crypto_version // 2-byte BE length + 12 bytes ("lo-crypto-v1")
|| len(Alice.IK_pub) || Alice.IK_pub // 2-byte BE length + 3200 bytes
|| len(Bob.IK_pub) || Bob.IK_pub // 2-byte BE length + 3200 bytes
|| len(EK_pub) || EK_pub // 2-byte BE length + 1216 bytes
session_key = HKDF(
salt = 0x00 * 32,
ikm = ikm,
info = info,
len = 64
)
root_key = session_key[0..32]
epoch_key = session_key[32..64]
zeroize(session_key) // 64-byte HKDF output — intermediate buffer containing entropy
// derived from kem_ss; MUST be zeroized after split. In Rust,
// wrapping in Zeroizing<[u8; 64]> handles this automatically on
// drop. Non-RAII implementations (C, Go, Python) MUST explicitly
// zero this buffer before returning or after the split — failing
// to do so leaves 64 bytes of key-derived material on the heap.
session_key must be zeroized after the split: The 64-byte HKDF output is an intermediate value containing both root_key and epoch_key. After splitting, the original session_key buffer still holds both secrets in cleartext and must be explicitly zeroized. In Rust, wrapping the buffer in Zeroizing<Vec<u8>> covers this automatically at drop. In C, a manual memset + compiler barrier (or explicit_bzero) is required. In Go, clear(sessionKey) after the copy. Failing to zeroize leaves a 64-byte window containing both the root key and the epoch key — more sensitive than either half alone.
session_key is derived with a single 64-byte HKDF call, then split positionally: The len = 64 HKDF call produces one 64-byte output; root_key and epoch_key are the first and second 32-byte halves respectively. This is NOT two separate HKDF invocations with distinct info labels — both halves come from the same Expand output. A reimplementer familiar with TLS 1.3's derive_secret (which calls HKDF-Expand-Label separately for each derived key with distinct labels and distinct context hashes) must not apply that pattern here. Using two separate HKDF calls with info = "root" and info = "epoch" (or any labeled split) produces different root_key and epoch_key values — both parties derive the same incorrect keys and AeadFailed results with no diagnostic.
Why zero salt: The IKM (ss_ik || ss_spk [|| ss_opk]) is already uniformly distributed high-entropy material — each shared secret is the output of an X-Wing KEM which by design produces pseudorandom bytes. HKDF's Extract step (the salt-keyed PRF) adds entropy from the salt to the IKM; when the IKM is already uniform, a non-secret salt (like zeros) provides no additional entropy benefit. A non-zero salt derived from session metadata would add complexity and a new parameter without a cryptographic gain. The choice of the default zero salt follows RFC 5869 §2.2's recommendation for this exact scenario.
Zero salt is 32 explicit zero bytes, not empty/null: The salt = 0x00 * 32 is the RFC 5869 §2.2 default for HKDF-SHA3-256 (HashLen = 32). Libraries that accept a null or empty salt MUST be verified to internally substitute the 32-zero-byte default — passing an empty byte slice (length 0) to HKDF's Extract step produces a different PRK than passing [0x00] × 32 (length 32) in many implementations. Go's golang.org/x/crypto/hkdf.New treats nil salt as "use HashLen zeros" but an explicit empty []byte{} may not — behavior varies by library. A reimplementer who passes nil in one language and [0x00; 32] in another gets interop failure with no diagnostic.
IKM concatenation order is critical: The order ss_ik || ss_spk [|| ss_opk] must be followed exactly — any reordering produces a different session key. Both parties derive IKM in the same order (Alice from encapsulation, Bob from decapsulation). The 64-byte and 96-byte IKM variants are not interchangeable. A reimplementer who zero-pads the absent ss_opk slot (passing 96 bytes with ss_opk = 0x00{32}) produces a different HKDF output than the specified 64-byte IKM — HKDF's Extract step processes the full input length, so ss_ik || ss_spk and ss_ik || ss_spk || 0x00{32} yield different PRKs. This manifests as AeadFailed at decrypt_first_message with no diagnostic.
IKM order is a documentation-only guarantee — no type-level enforcement: The ss_ik ‖ ss_spk [‖ ss_opk] concatenation order is specified above but not enforced at the type level. All three shared secrets have the same type (xwing::SharedSecret), so the encapsulation calls (or decapsulation calls on Bob's side) and the IKM concatenation can be reordered without a compile error. Any such reordering produces a different session key with no error at the HKDF step — the mismatch surfaces only as AeadFailed at decrypt_first_message with no diagnostic pointing to the ordering change. In the Rust implementation, the session_agreement_with_opk integration test is the only runtime guard against an order-breaking refactor. Any change to the encapsulation sequence in §5.4 Step 3, the decapsulation sequence in §5.5 Step 4, or the IKM concatenation in either step MUST verify that test still passes with matching keys on both sides.
Shared secret zeroization after IKM construction: After constructing ikm by concatenating ss_ik, ss_spk, and (optionally) ss_opk, each individual shared secret MUST be zeroized immediately. Copying a secret into a concatenation buffer creates an independent copy — the original remains on the heap (or stack) until explicitly zeroized. In Rust, Zeroizing<Vec> covers the concatenated buffer but .extend_from_slice() does not zeroize the source. In C, memcpy into ikm leaves the originals in their allocations. In Go, slice append does not zero the source. Forgetting this step leaks up to 96 bytes of shared secret material.
All len() values are 2-byte big-endian. The total info length is 7645 bytes (9 + 2 + 12 + 2 + 3200 + 2 + 3200 + 2 + 1216). The crypto_version field (added for cross-version domain separation) appears immediately after the raw prefix, before the identity keys.
Why length prefixes are used on fixed-size identity keys in HKDF info: Unlike §7.4 (AAD encoding, where fingerprints and public keys are written bare because their sizes are fixed by definition within a given crypto_version), the HKDF info field here applies len(x) || x encoding uniformly to all post-prefix fields. The rationale: (1) bit-string prefix-freeness — length-prefixed encodings are prefix-free, ensuring no valid info field for one set of inputs is a proper prefix of a valid info for another set, which is required for HKDF's domain separation guarantee to hold; (2) future version safety — a lo-crypto-v2 with different key sizes would change the field lengths; without length prefixes, a 3200-byte IK_pub in v1 and a differently-sized IK_pub in v2 would produce non-colliding info strings naturally (different sizes), but a uniform encoding convention ensures this by construction regardless of actual size changes; (3) consistency — crypto_version is genuinely variable-length, so all fields use the same encoding rule for simplicity. A reimplementer who omits the length prefixes from the fixed-size fields (treating them as optional "since the size is known") produces a different HKDF output — the missing 8 bytes of length prefixes shift the info bytes, producing a completely different session key with no diagnostic.
Why IK is both encapsulated to AND in info: IK encapsulation contributes ss_ik to the IKM, meaning Bob's IK private key is required to derive the session key. Binding both identity keys into HKDF info provides mutual authentication — substituting either key yields a different session key. See §5.6 for security analysis.
Why info includes EK_pub: Alice's ephemeral key EK_pub contributes no shared secret to the IKM (it is a KEM public key, not a DH key — shared secrets come from encapsulating to Bob's keys). Including EK_pub in info binds the session key to the specific ephemeral key Alice published. Without this binding, an active attacker could substitute a different sender_ek in the SessionInit while keeping the KEM ciphertexts intact — Bob's decapsulations would still succeed (the ciphertexts are bound to Bob's keys, not Alice's EK), but Bob's first KEM ratchet encapsulation (§6.4) would target the attacker's key instead of Alice's. With EK_pub in info, substituting sender_ek changes the HKDF output, causing decrypt_first_message to fail at AEAD.
Why info excludes SPK, OPK, and ciphertexts: SPK and OPK binding flows through the IKM path — only the holder of sk_SPK can produce ss_spk, and only the holder of sk_OPK can produce ss_opk. Including SPK/OPK public keys or ciphertexts in info would be redundant. For formal models: SPK/OPK binding is an IKM-path property (KEM correctness), not an info-path property (HKDF domain separation).
Step 5: Construct Session Init
SessionInit = {
crypto_version: "lo-crypto-v1"
sender_ik_fingerprint: SHA3-256(Alice.IK_pub) [32 bytes raw]
recipient_ik_fingerprint: SHA3-256(Bob.IK_pub) [32 bytes raw]
sender_ek: EK_pub [1216 bytes]
ct_ik: X-Wing ciphertext [1120 bytes]
ct_spk: X-Wing ciphertext [1120 bytes]
spk_id: uint32
ct_opk: X-Wing ciphertext [1120 bytes, optional]
opk_id: uint32 [optional]
}
Encoded size: The encode_session_init output is 3,543 bytes (no OPK) or 4,669 bytes (with OPK). The OPK block adds exactly 1,126 bytes when present: 2 bytes (BE length prefix for ct_opk) + 1,120 bytes (ct_opk) + 4 bytes (opk_id as u32 BE). The 1-byte has_opk flag is always encoded (as part of the 3,543-byte base) — it is not part of the 1,126-byte increment. See §7.4 and Appendix C for the full field-by-field breakdown.
sender_ik_fingerprint lets Bob look up Alice's full identity key. Full IK not sent (bandwidth); Bob resolves from community context or prior knowledge.
recipient_ik_fingerprint names the intended recipient explicitly inside the signed payload. Since SessionInit is signed by Alice in Step 6, Bob can derive recipient binding from sender_sig alone — without reasoning about the KEM ciphertexts' implicit binding to Bob's keys. This simplifies formal verification: a Tamarin or ProVerif model can prove recipient binding as a direct property of the signature, rather than as a consequence of KEM decapsulability.
Step 6: Sign Session Init
Alice proves to Bob that she initiated this session (and possesses sk_IK_A):
session_init_bytes = encode_session_init(SessionInit)
sender_sig = HybridSign(Alice.IK_sk, "lo-kex-init-sig-v1" ‖ session_init_bytes)
Raw concatenation — no length prefixes (see Appendix A). sender_sig is a 3373-byte hybrid signature (Ed25519 64 bytes + ML-DSA-65 3309 bytes). The total signed message is "lo-kex-init-sig-v1" (18 bytes) || session_init_bytes (3543 or 4669 bytes) = 3561 bytes (no OPK) or 4687 bytes (with OPK). The label prefix is not length-delimited — it abuts session_init_bytes directly. sender_sig is transmitted alongside SessionInit and the first-message payload. Bob verifies it in §5.5 Step 3 before performing any KEM operations. The domain separator "lo-kex-init-sig-v1" prevents replay into any other signature context (§3.3). Canonical wire order: the three components are assembled as session_init_bytes ‖ sender_sig ‖ encrypted_payload — this order is defined and elaborated at the receiving side (§5.5 Step 3), which is where a receiver parses the three components. Alice MUST produce this order; Bob verifies in this order.
Step 7: Encrypt First Message
msg_key = KDF_MsgKey(epoch_key, 0) // Counter 0 for the first message
nonce = random_bytes(24) // Random for first message (defense in depth)
// session_init_bytes computed in Step 6 above
aad = "lo-dm-v1" // 8 bytes
|| Alice.fingerprint_raw (32 bytes)
|| Bob.fingerprint_raw (32 bytes)
|| session_init_bytes
ciphertext = AEAD(
key = msg_key,
nonce = nonce,
plaintext = message_content,
aad = aad
)
// Zeroize msg_key immediately after use — secret material.
zeroize(msg_key)
encrypted_payload = nonce || ciphertext // nonce prepended for decryption
The AAD binds the full session init structure. The session_init_bytes used here is the same encoding produced in Step 6 — it is computed once and reused verbatim, not re-encoded. Tampering with any session init field (ct_ik, ct_spk, sender_ek, spk_id, etc.) invalidates the AEAD tag. See §7.4 for the deterministic encoding.
No length prefixes in AAD fields: The "lo-dm-v1" AAD is raw concatenation — "lo-dm-v1" || sender_fp || recipient_fp || session_init_bytes — with no BE length prefixes separating the fields. This contrasts with the HKDF info construction in Step 4 (§5.4 Step 4), where each field is length-prefixed to prevent cross-context collisions. In the AAD, collisions are impossible structurally: sender_fp and recipient_fp are both fixed-width (32 bytes each), and session_init_bytes is the remainder. A reimplementer who applies the HKDF info length-prefix rule to the AAD produces different bytes and will see AEAD authentication failure on every first message.
build_first_message_aad / build_first_message_aad_from_encoded rejects empty si_encoded with InvalidData: An empty session_init_bytes input is rejected because an empty AAD suffix would strip the per-session binding — the AAD would degenerate to "lo-dm-v1" || sender_fp || recipient_fp with no session-init bytes, identical to what any first-message AAD would look like for the same two parties. encode_session_init never produces empty output (minimum 3,543 bytes), so this guard fires only on caller bugs — but it is a normative invariant of the AAD construction and MUST be enforced by reimplementers. InvalidData is correct (not InvalidLength) — an empty si_encoded is a structural protocol violation (a legitimate SessionInit has a minimum encoded size), not a buffer-size mismatch.
Why random nonce for the first message: The message key is unique (derived from a unique epoch key and counter), so a counter-based nonce would be safe. Random provides defense-in-depth: if a bug ever causes key reuse, random nonce prevents the catastrophic AEAD nonce-reuse failure mode.
First-message msg_key zeroization: msg_key is secret key material — it MUST be zeroized immediately after AEAD encryption completes. In Rust, wrapping the output of KDF_MsgKey in Zeroizing handles this automatically via Drop. In C/Go/Python, the caller must explicitly zeroize the key buffer after use. The same obligation applies to Bob's first-message decryption path (§5.5 Step 6).
Epoch key passthrough: The epoch_key (session-derived) becomes the epoch key passed to RatchetState::init_alice. Unlike the previous chain-ratchet design, the epoch key is not advanced by the first-message encryption — it is passed through unchanged. Name aliases: this value appears under three names across the spec, Rust API, and CAPI — epoch_key (this section), initial_chain_key (CAPI field name in SolitonInitiatedSession and Rust take_initial_chain_key() method name — both use the same initial_chain_key base name), and ratchet_init_key (CAPI first-message return). See §13.5 for the full list. send_count starts at 1 so that counter 0 is not reused by the ratchet (the first message consumed counter 0 with a random nonce). Counter 0 namespace partition: The send_count = 1 initialization is the sole mechanism preventing counter collision between the first-message path (encrypt_first_message at counter 0 with a random nonce) and the ratchet path (encrypt() starting at counter 1 with a counter-based nonce). Both paths derive message keys from the same epoch key via KDF_MsgKey(epoch_key, counter) — if a reimplementer initializes send_count = 0, the first encrypt() call produces the same msg_key as encrypt_first_message, with different nonces (counter-based vs. random) but identical AEAD keys. No runtime guard prevents this — the protection is purely structural (initialization value).
5.5 Session Reception (Bob)
Bob receives the session init (real-time or from offline queue).
Step 1: Resolve Alice's Identity
Bob uses sender_ik_fingerprint to look up Alice's full identity key from local cache, community server context, or prior knowledge. If unknown, Bob's client SHOULD indicate this is an unverified first contact.
This lookup is the sole identity binding. The library verifies that alice_ik_pk is self-consistent with the session (fingerprint matches sender_ik_fingerprint, signature is valid under that key), but it cannot verify that alice_ik_pk actually belongs to the human "Alice." If the caller supplies the wrong key — or an attacker's key — signature verification succeeds (the attacker signed the SessionInit with the corresponding private key), and the session is authenticated to the attacker, not Alice. The caller's key-lookup code is the only thing standing between "authenticated session with Alice" and "authenticated session with an adversary." See Appendix E, Caller Obligation 1.
TOFU key pinning obligation: On first contact (no prior key record for this fingerprint), the caller MUST record the association between sender_ik_fingerprint and alice_ik_pk immediately after a successful receive_session. On subsequent contacts from the same fingerprint, the caller MUST verify that alice_ik_pk matches the previously recorded key — presenting a different key for the same fingerprint MUST trigger a key-change warning. A caller who fails to pin the key after first contact and fails to verify on subsequent contacts accepts TOFU impersonation silently: an attacker controlling the relay can substitute a different key pair on every session and the library will accept each substitution as valid. Key pinning is the caller's responsibility — the library provides the fingerprint but does not maintain a key store.
Bob also validates:
crypto_version == "lo-crypto-v1"SHA3-256(Alice.IK_pub) == si.sender_ik_fingerprint// MUST be constant-time — see belowSHA3-256(Bob.IK_pub) == si.recipient_ik_fingerprint// MUST be constant-time — see below
Fingerprint comparisons MUST be constant-time: Both SHA3-256(Alice.IK_pub) == si.sender_ik_fingerprint and SHA3-256(Bob.IK_pub) == si.recipient_ik_fingerprint are comparisons of 32-byte digests that must use constant-time equality. These checks precede HybridVerify (Step 3). A variable-time comparison here allows an attacker to probe the expected sender_ik_fingerprint value byte-by-byte by submitting crafted session inits and timing the comparison — they learn one byte per probe without paying the HybridVerify cost (~2 ms). After 32 probes, they know the stored fingerprint value. This allows targeted construction of sessions that pass the fingerprint check while carrying a fraudulent public key. Appendix E's constant-time table documents fingerprint comparison in §6.12 (ratchet); this requirement applies equally in the KEX context. soliton uses subtle::ConstantTimeEq for both comparisons.
Why receive_session does not need oracle-collapse: receive_session does NOT collapse errors to a generic failure. This is intentional and safe: the values being checked — crypto_version (cleartext), sender_ik_fingerprint (cleartext, transmitted in the SessionInit), and recipient_ik_fingerprint (cleartext, the receiver's own identity) — are all known to the sender who constructed the SessionInit. A timing leak on any of these checks reveals nothing the attacker did not already supply or know. This contrasts with verify_bundle (which collapses to prevent bundle-content enumeration) and LO-Auth (which collapses to prevent authentication-step enumeration). The comparisons still MUST be constant-time (§Appendix E) to prevent reconstruction of the receiver's stored fingerprint value, but the error codes themselves need not be collapsed.
Error collapsing in verify_bundle vs receive_session: The verify_bundle function (§5.3) collapses all non-structural failures (crypto version mismatch, fingerprint mismatch, signature verification failure) to the single BundleVerificationFailed error. Returning distinct errors would create an enumeration oracle — an attacker could iteratively probe which validation step failed, revealing information about the bundle contents. Exception: the OPK structural co-presence check (opk_pub and opk_id must be both present or both absent) returns InvalidData, not BundleVerificationFailed. This check runs before the IK comparison and signature verification — it is a pre-cryptographic structural validation, not a security-sensitive check that requires collapse. Callers pattern-matching on verify_bundle errors must handle both BundleVerificationFailed and InvalidData. receive_session does NOT collapse errors — it returns UnsupportedCryptoVersion for a bad crypto version and InvalidData for fingerprint mismatches. No pre-key bundle is involved in receive_session, so the bundle-level collapse does not apply; the SessionInit fields being checked (crypto version, fingerprints) are already visible to the sender who constructed them.
Step 2: Validate OPK Co-Presence
OPK fields must both be present or both be absent. Failure → abort with InvalidData. This is a structural validation on the parsed SessionInit, not a cryptographic operation — executing it before signature verification avoids unnecessary signature/KEM work on malformed messages.
Two distinct co-presence checks — only one is pre-signature: Step 2 validates the structural co-presence of ct_opk and opk_id within the decoded SessionInit (both fields present or both absent). This check is pre-signature. A separate caller co-presence check — whether the caller supplied opk_sk if and only if ct_opk is present — fires at Step 4, after HybridVerify. The caller check MUST be post-signature: moving it to Step 2 would create an OPK-presence oracle (an attacker could distinguish "OPK present, no opk_sk provided" from "OPK absent" before signature verification, probing the receiver's key state without completing authentication). A reimplementer who consolidates both checks at Step 2 enables this oracle. The §5.5 Step 4 note documents the post-signature placement rationale.
The OPK co-presence check is enforced inside encode_session_init: In the reference implementation, this check fires from within encode_session_init (called at Step 3 to reconstruct the signed message bytes), not as an independent pre-step. A reimplementer who constructs the signed message manually — by concatenating SessionInit fields directly without calling encode_session_init — bypasses this guard entirely and reaches KEM operations (Step 4) with a structurally invalid SessionInit. Any reimplementation MUST perform the OPK co-presence check explicitly before HybridVerify if encode_session_init is not used to reconstruct the signed bytes.
Step 3: Verify Initiator Signature
session_init_bytes = encode_session_init(received_session_init)
HybridVerify(Alice.IK_pub, "lo-kex-init-sig-v1" ‖ session_init_bytes, sender_sig)
session_init_bytes MUST be reconstructed by calling encode_session_init, not extracted from the wire: The signed message "lo-kex-init-sig-v1" ‖ session_init_bytes uses the canonical encoding of the parsed SessionInit struct — the output of encode_session_init. The session_init_bytes are NOT transmitted as an opaque blob alongside the signature; the wire format is session_init_bytes || sender_sig || encrypted_payload (§5.4 Step 6), but the verifier re-encodes from the parsed struct rather than slicing the wire buffer. A reimplementer building a signature verifier who tries to extract session_init_bytes from the wire — e.g., slicing wire[0..3543] — must verify that their wire-slice produces byte-for-byte identical output to encode_session_init. Any normalization of SessionInit fields during parsing (key clamping, padding removal, normalization of the X25519 component) that changes the bytes on re-encoding causes VerificationFailed on an authentic session init.
Verification failure → abort with VerificationFailed. This provides cryptographic proof that the session was initiated by the holder of sk_IK_A, preventing zero-knowledge impersonation: an adversary who knows only pk_IK_A cannot produce a valid sender_sig without sk_IK_A. This step executes before any KEM operations; a forged or absent signature is rejected immediately, not silently.
Verifier bytes obligation: HybridVerify must receive the raw bytes of sender_ek exactly as stored and transmitted — no normalization, clamping, or bit-masking applied to any sub-key component. If the verifier's X25519 library normalizes public keys on import (e.g., clears bit 255 of byte 31 — see §8.1), the verified bytes differ from the signed bytes and VerificationFailed results even with an authentic session init. The same obligation applies to SPK verification in §5.3. See §8.1 for the X25519 masking hazard that most commonly triggers this.
Validation ordering rationale: The three pre-signature checks (crypto version, sender fingerprint, recipient fingerprint — Steps 1-2) are cheaper than HybridVerify (which performs Ed25519 + ML-DSA-65 verification). Running them first avoids the cost of two signature verifications on messages that would fail a trivial structural check. A reimplementer who reorders signature verification before the fingerprint checks wastes CPU on forged messages and gains no security benefit — all four checks are required before proceeding to KEM operations regardless of order.
sender_sig is transmitted alongside the session init and first-message payload; it is not part of the SessionInit struct and is not included in the AAD (it covers the encoded SessionInit).
Canonical wire order: The three components are assembled as session_init_bytes || sender_sig || encrypted_payload — session init first (fixed or deterministic size given has_opk), then the signature (fixed 3373 bytes), then the encrypted payload (variable length). All three have deterministic sizes: session_init_bytes is 3543 bytes without OPK or 4669 bytes with OPK (§7.4, Appendix F.13), sender_sig is always 3373 bytes (§3.3). A receiver must consume all 3543 bytes of the fixed prefix before reaching the has_opk flag at offset 3542 (the last byte of the fixed prefix), which determines whether the remaining 1126 bytes of OPK data follow. The receiver then reads exactly 3373 bytes of sender_sig, and the remainder is encrypted_payload. The CAPI returns these as separate fields; callers assembling wire messages MUST use this order.
Step 4: Decapsulate
// Decapsulate IK ciphertext
ss_ik = XWing.Decaps(ExtractXWingPrivate(Bob.IK_sk), ct_ik)
// Decapsulate SPK ciphertext
ss_spk = XWing.Decaps(Bob.SPK_sk[spk_id], ct_spk)
// Decapsulate OPK ciphertext (if present)
if ct_opk is present:
ss_opk = XWing.Decaps(Bob.OPK_sk[opk_id], ct_opk)
// Delete OPK_sk[opk_id] immediately — single use
Caller co-presence obligation: The caller must provide opk_sk if and only if ct_opk is present in the SessionInit.
- soliton does: If
ct_opkis present butopk_skis not provided (e.g., the OPK was already consumed and deleted),receive_sessionreturnsInvalidData— but only after signature verification (Step 3), so this check cannot be used as an oracle. - What a broken reimplementation sees instead: A reimplementer who silently skips OPK decapsulation when
opk_skis unavailable (omittingss_opkfrom the IKM) does NOT getInvalidDataatreceive_session—receive_sessionsucceeds. The session key diverges from Alice's, and the error surfaces only asAeadFailedatdecrypt_first_messagewith no diagnostic pointing to the missing OPK decapsulation. The active guard (InvalidDataon missingopk_sk) is the only mechanism that surfaces this condition as a clear error; omitting the guard silently accepts a broken session.
The converse is also InvalidData: if ct_opk is absent but opk_sk is provided, the session init contains no OPK ciphertext to decapsulate. Silently ignoring a surplus opk_sk would mask a caller error where the wrong OPK was retrieved.
OPK deletion is a forward secrecy boundary. While sk_OPK survives, a three-key compromise (sk_IK + sk_SPK + sk_OPK) recovers this session's key. After deletion, only two-key compromise (sk_IK + sk_SPK) suffices — the OPK's contribution to IKM is lost. "Immediately" means before the ratchet state is used for any messaging — not deferred to a background task or garbage collector. Any delay between decapsulation and deletion is a forward secrecy window where the three-key compromise remains viable.
The caller, not the library, performs the OPK deletion. receive_session accepts opk_sk as a shared reference (&xwing::SecretKey); the library decapsulates but holds no handle to persistent storage and cannot remove the OPK key from the caller's keystore. The caller MUST delete the OPK from persistent storage at the call site, immediately after receive_session returns successfully, before passing the resulting ratchet state to any messaging function. A reimplementer who expects the library to delete the OPK automatically, or who defers deletion to a separate "cleanup" pass, retains the key beyond the intended forward-secrecy boundary.
OPK deletion MUST be atomic with receive_session (single DB transaction): A server that completes receive_session and then crashes before deleting the OPK from storage will accept the same session_init again on restart — the OPK is still present, so the co-presence check passes. The second receive_session call succeeds and produces a second ratchet state from the same OPK decapsulation, violating the single-use guarantee. The correct model: execute receive_session and the OPK deletion as a single atomic database transaction. If receive_session succeeds, commit the transaction (which atomically deletes the OPK and persists the ratchet state). If the server crashes before the commit, the transaction rolls back and the OPK remains for the retried session init. If the server crashes after the commit, the OPK is deleted and the ratchet state is persisted — the session init is rejected on retry (ct_opk present but OPK deleted → InvalidData post-verification).
If spk_id does not match any retained SPK (all rotated out or invalid ID), the caller MUST reject the session init with InvalidData — but only after signature verification (Step 3). Checking spk_id before signature verification would create an SPK enumeration oracle: an attacker could probe which SPK IDs are retained without paying the signature cost. After signature verification confirms the session init is authentic, an unrecognized spk_id is safely rejected. Using the wrong SPK key instead of rejecting yields implicit rejection, producing a diverged session key and AEAD failure cryptographically indistinguishable from corruption. Expired SPKs (private key deleted after the 30-day retention window, §10.2) must be handled identically to unknown SPKs — return InvalidData post-signature-verification. Maintaining a separate "expired" vs "unknown" error would reintroduce the enumeration oracle that post-signature ordering is designed to prevent.
X-Wing implicit rejection (§8.4) applies to all three decapsulations — ct_ik, ct_spk, and ct_opk. Invalid or tampered ciphertexts produce pseudorandom shared secrets rather than errors, and the derived session key diverges silently from Alice's. decrypt_first_message fails at AEAD with no indication of which decapsulation diverged. Reimplementers using ML-KEM libraries with explicit-rejection APIs (that return an error on invalid ciphertexts) MUST suppress those errors and use the implicit-rejection output — propagating DecapsulationFailed would leak which ciphertext was malformed.
If opk_id references an absent OPK (expired or already consumed), the same applies — the pseudorandom shared secret from implicit rejection causes AEAD failure, leaking no information about OPK validity.
ML-KEM key format hazard: The ml-kem crate (and soliton's X-Wing §8.5) stores ML-KEM-768 decapsulation keys in NTT-domain encoding — the 1152-byte dk_PKE field contains polynomials in Number Theoretic Transform representation, not the coefficient-domain encoding specified in FIPS 203 §7.3 DecapsKeyGen. Reimplementers sourcing ML-KEM keys from other libraries (liboqs, PQClean, BouncyCastle) that use FIPS 203's coefficient-domain format MUST convert to NTT-domain before using them with soliton's X-Wing decapsulation. Using the wrong domain produces a pseudorandom shared secret (implicit rejection), causing silent AeadFailed at decrypt_first_message with no diagnostic pointing to the format mismatch. See §8.5 for the full key layout.
Diagnostic note — correct spk_id with wrong secret key: An unrecognized spk_id is caught explicitly (rejected as InvalidData post-signature-verification). A recognized spk_id paired with the wrong secret key (e.g., a storage corruption that maps a valid ID to a different key) is not caught at this step — ML-KEM implicit rejection produces a pseudorandom ss_spk, receive_session returns success, and the error surfaces only when decrypt_first_message fails with AeadFailed. No diagnostic distinguishes this from ciphertext tampering, transport corruption, or any other decapsulation divergence. This is the hardest SPK storage bug to diagnose. Implementations that maintain an spk_id → sk mapping SHOULD verify the mapping's integrity independently (e.g., by storing a fingerprint of the public key alongside the private key and checking it before decapsulation).
Diagnostic note — correct opk_id with wrong secret key: The same applies to OPK: a recognized opk_id paired with the wrong opk_sk (storage corruption mapping a valid OPK ID to different key material) produces a pseudorandom ss_opk via implicit rejection, receive_session returns success, and the error surfaces only as AeadFailed at decrypt_first_message. Unlike the SPK case, OPK keys are single-use and deleted immediately after decapsulation (§5.5 Step 4), so long-term storage corruption is less likely — but the failure mode is identical. Implementations SHOULD store an OPK public key fingerprint alongside the OPK secret key and verify it before decapsulation, the same as for SPK.
Step 5: Derive Session Key
Identical HKDF as Alice (§5.4 Step 4), using:
ikm:ss_ik || ss_spk [|| ss_opk]info: Alice's IK_pub, Bob's IK_pub, Alice's EK_pub (from session init)
Produces identical root_key and epoch_key.
IKM zeroization obligation (identical to §5.4 Step 4): After HKDF output is split into root_key and epoch_key, zeroize the IKM (ss_ik || ss_spk [|| ss_opk]) and each component shared secret (ss_ik, ss_spk, ss_opk). These are uniformly distributed 32-byte KEM shared secrets — leaving them in memory after use enables an attacker with post-compromise memory access to recover root_key and epoch_key. See §5.4 Step 4 for the full zeroization rationale and the note on IKM concatenation buffer zeroization (the concatenated buffer holds copies of all shared secrets and must be zeroized independently of the individual components).
Initiator-first ordering, not local-first: Alice's identity key precedes Bob's in the HKDF info on both sides — Alice uses Alice.IK_pub || Bob.IK_pub and Bob also uses Alice.IK_pub || Bob.IK_pub. The ordering is determined by the initiator/responder role, not by which party is doing the computation. A reimplementer who reads "identical HKDF as Alice" as "local key first, remote key second" would swap the order on Bob's side, producing a different session key — both parties succeed at their own computation with no error; the mismatch surfaces only as AeadFailed at decrypt_first_message.
sender_ek (Alice's EK_pub) in HKDF info MUST be the raw bytes from the received session init — no normalization: The "Verifier bytes obligation" in Step 3 covers signature verification; the same no-normalization requirement applies here. Bob's HKDF info computation uses sender_ek (the X-Wing public key Alice transmitted), and it MUST be the raw received bytes, not a library-imported-and-re-exported form. If Bob's X25519 library normalizes the public key at import (e.g., clears bit 255 of the last byte — the high bit is masked in RFC 7748 §5 scalar multiplication), the normalized bytes differ from Alice's transmitted bytes, the HKDF info diverges, and decrypt_first_message fails with AeadFailed with no diagnostic pointing to the normalization. The fix: use the raw session_init.sender_ek bytes directly in the info construction without passing them through a library's key import path. See §8.1 for the X25519 masking hazard. The no-normalization obligation for signatures (Step 3) is explicitly documented there; this is the equally critical, less obvious HKDF-side obligation.
Step 6: Decrypt First Message
msg_key = KDF_MsgKey(epoch_key, 0) // Counter 0 for the first message
// Reconstruct AAD from received session init
session_init_bytes = encode_session_init(received_session_init)
aad = "lo-dm-v1" || Alice.fingerprint_raw || Bob.fingerprint_raw || session_init_bytes
// Guard: reject payloads too short to contain a nonce + Poly1305 tag.
// Minimum valid length is 40 bytes (24-byte nonce + 16-byte tag). Payloads
// shorter than 40 bytes cannot contain a valid nonce — slicing [0..24] on
// a sub-24-byte buffer causes out-of-bounds access in C or a panic in Rust.
// Return AeadFailed (not InvalidLength) — see §12 oracle-collapse rationale.
if len(encrypted_payload) < 40:
raise AeadFailed
// Extract nonce from payload
nonce = encrypted_payload[0..24]
ciphertext = encrypted_payload[24..]
// Zeroize msg_key immediately after use — secret material.
plaintext = AEAD-Decrypt(msg_key, nonce, ciphertext, aad)
zeroize(msg_key)
Bob's encode_session_init(received_session_init) must produce byte-for-byte identical output to Alice's Step 6 encoding — any field transformation during decode (padding trimming, key clamping, normalization) that alters re-encoded bytes causes silent AEAD failure with no diagnostic.
AeadFailed conflation is normative — MUST NOT add distinguishing codes: All AEAD authentication failures in decrypt_first_message — whether caused by a wrong session key (diverged KEM output), a tampered nonce, a modified AAD, a corrupt ciphertext, or a re-encoded session_init_bytes that differs from Alice's original — MUST return AeadFailed with no distinguishing information. Reimplementers MUST NOT return distinct error codes for these cases (e.g., a separate KeyDerivationMismatch or AadMismatch). Adding distinguishing codes creates an oracle: an attacker who can trigger specific errors knows which layer of the construction failed, enabling targeted substitution attacks. The single AeadFailed response forces the attacker to succeed at AEAD authentication — i.e., to know the key — to get any response other than failure. This requirement also applies to receive_session as a whole: VerificationFailed (Step 3) and AeadFailed (Step 6) must remain the only cryptographic-layer failure codes, not be further subdivided.
First-message msg_key zeroization (Bob): msg_key MUST be zeroized after AEAD decryption completes — it is secret material. In Rust, Zeroizing<[u8; 32]> handles this automatically. In C/Go/Python, explicitly zeroize the key buffer after AEAD-Decrypt returns. The same obligation applies on Alice's encrypt path (§5.4 Step 7).
Step 7: Initialize Ratchet State
Bob initializes LO-Ratchet with:
root_keyfrom key derivationrecv_epoch_key=epoch_key(the session-derived key, now used as the receive epoch key)recv_ratchet_pk= Alice'sEK_pub(from session init)ratchet_pending = true(Bob must perform a KEM ratchet step before his first send)recv_countstarts at 1 (the session-init message used counter 0). Corollary: a ratchet header withn = 0will fail the duplicate check (0 < recv_count = 1) and be rejected asDuplicateMessage. Counter 0 is permanently outside the ratchet's receive window — it belongs todecrypt_first_message, not the ratchet. A reimplementer who initializesrecv_count = 0instead of 1 would acceptn = 0as a valid ratchet message, creating a counter alias with the first-message counter and enabling replay of the session-init payload as a ratchet message (AEAD would fail due to AAD mismatch, but the acceptance represents a protocol divergence). Thisrecv_count = 1invariant is a construction-time guarantee, not enforced at deserialization: the §6.8 guards do not reject a deserialized blob withrecv_count = 0. A cross-implementation blob constructed withrecv_count = 0is silently accepted by the reference deserialization. The deserialization path trusts the invariant was maintained during construction. Reimplementers who allowrecv_count = 0at init time (e.g., for testing or partial state reconstruction) produce blobs that the reference accepts, but with state that violates the counter-alias-free guarantee above.recv_seen= empty (counter 0 was consumed bydecrypt_first_message, outside the ratchet)- Bob generates his own ratchet keypair only on first reply (triggered by
ratchet_pending)
Session init replay — library boundary: receive_session does not detect or reject replayed session inits. A replayed session init carries a valid signature (it was signed by Alice), valid KEM ciphertexts, and passes all library-layer checks. If the same session_init_bytes is submitted to receive_session a second time (with a still-present OPK), a second ratchet state is created from the same KEM outputs — two live ratchet objects initialized identically, with the same root key and epoch key, in different memory locations. The library has no persistent session registry and cannot distinguish a replay from a legitimate first delivery.
Replay detection is the caller's responsibility. The correct architecture:
- The relay MUST deduplicate session inits before delivering them to Bob's device — the natural deduplication key is
(sender_ik_fingerprint, recipient_ik_fingerprint, SHA3-256(session_init_bytes)). A relay that delivers the same session init twice creates the duplicate-ratchet-state condition. - Bob's client MUST enforce at-most-once semantics for session establishment with a given peer: if a ratchet session already exists for the
(sender_ik_fingerprint, spk_id, ct_ik)combination, the client MUST NOT callreceive_sessiona second time with the same session init. - The OPK single-use delete-in-transaction requirement (§5.5 Step 4) provides a partial backstop: once the OPK is deleted, a replayed
ct_opk-bearing session init fails withInvalidDataat the co-presence check. However, OPK-less session inits (noct_opk) have no such backstop and rely entirely on caller-side deduplication.
receive_session exposing this boundary as a caller obligation (rather than adding a session registry inside the library) is intentional — the library has no persistent storage and cannot implement relay-side deduplication. Applications building atop soliton MUST implement the deduplication layer at the relay and client levels described above.
5.6 Security Analysis
Multi-key session binding: The session key requires ALL shared secret components. No single key compromise is sufficient. Note: "IK" in the table below means the X-Wing component only (bytes 0-2431 of the LO composite secret key — see clarification after the table).
| Keys compromised | Session key recoverable? |
|---|---|
| IK (X-Wing component) alone | No — missing ss_spk |
| SPK alone | No — missing ss_ik |
| OPK alone | No — missing ss_ik, ss_spk |
| IK (X-Wing component) + SPK | Yes (same as X3DH / PQXDH) |
| IK (X-Wing component) + SPK + OPK | Yes |
"IK" in this table means the X-Wing component only: Session key recovery via IK requires the X-Wing private key (sk_X || dk_M, bytes 0-2431 of the LO composite secret key) — the component needed to decapsulate ct_ik. The Ed25519 and ML-DSA sub-keys within the LO composite key do not participate in key agreement and are irrelevant to session key recovery. A full LO composite key compromise (sk_IK) trivially yields the X-Wing sub-key, so the security table holds. But an adversary who compromises only the Ed25519 or ML-DSA sub-keys (e.g., through an algorithm-specific attack) gains forgery capability (session initiation, SPK signing) but NOT session key recovery — IK KEM decapsulation is independent of the signing sub-keys.
SPK is the most exposed key (medium-term, stored on relay, retained 30 days after rotation). IK is long-term and device-stored only. Requiring both for session key recovery means the least-protected key is no longer a single point of failure.
Forward secrecy: Forward secrecy comes from SPK rotation and OPK single-use. After SPK private key is deleted, sessions using that SPK are permanently secure — even if IK is later compromised, the attacker lacks ss_spk.
EK_sk forward-secrecy window: Alice's ephemeral key EK_sk (§5.4 Step 2) must remain live until she successfully processes Bob's first KEM ratchet step (§6.6 new-epoch path), at which point it MUST be zeroized. Until zeroization, a device compromise allows an attacker to recover EK_sk and decapsulate Bob's first KEM ratchet ciphertext — recovering ss_spk_ratchet and therefore the initial epoch key. This exposes all messages in Alice's first ratchet epoch (from send_count = 1 through the first KEM ratchet step). This window is bounded and unavoidable: the key must exist until the decapsulation it enables occurs. It does not affect sessions that used an OPK (the OPK provides an additional shared secret layer), and it disappears as soon as Alice processes Bob's first ratchet reply. The EK_sk zeroization obligation is documented in §5.4 Step 2 and §13.5; the forward-secrecy implication is that the window is as long as the round-trip to Bob's first reply.
Post-quantum security: All shared secrets via X-Wing. Both X25519 and ML-KEM-768 must be broken simultaneously.
Mutual authentication: Both identity keys are cryptographically bound into the session. Bob's IK is bound via KEM encapsulation (ct_ik): only the holder of Bob's IK private key can decapsulate and derive the session key. Alice's IK is bound via a HybridSign over the encoded SessionInit (sender_sig, §5.4 Step 6): only the holder of Alice's IK private key can produce a valid signature.
Recipient binding — implicit and explicit: Bob's IK is bound implicitly by the KEM: an attacker lacking Bob's IK private key cannot decapsulate ct_ik and the session key they derive will be garbage. Bob's IK is also bound explicitly via recipient_ik_fingerprint embedded in the signed SessionInit: sender_sig directly names Bob as the intended recipient, independent of KEM decapsulability. Formal verification tools (Tamarin, ProVerif) can derive recipient binding from the signature alone, without modelling KEM implicit binding as a separate lemma.
UKS (Unknown Key Share) resistance: An Unknown Key Share attack would allow Alice to establish a session that Alice believes is with Bob, but Bob believes is with a third party C. LO-KEX prevents this via a three-link chain that must all hold simultaneously: (1) Bob validates SHA3-256(Bob.IK_pub) == si.recipient_ik_fingerprint (§5.5 Step 1) — this binds Bob's own key to the session before any KEM operation; (2) Bob verifies Alice's signature over session_init_bytes, which contains recipient_ik_fingerprint as a field — so the signature covers Bob's identity explicitly, not just cryptographic material that implies it; (3) both Alice.IK_pub and Bob.IK_pub are bound into the HKDF info field via build_kex_info — a session where Alice thinks she's talking to Bob but Bob thinks he's talking to C would require both parties to derive the same session key from different info inputs, which HKDF collision-resistance prevents. All three links are required: the fingerprint check alone fails if the attacker can substitute a key with the same fingerprint (SHA3-256 preimage resistance required); the signature check alone fails if the signature doesn't name the recipient (it does, via recipient_ik_fingerprint); the HKDF binding alone is not a direct authentication (both parties must independently check they are talking to the expected peer). This argument is documented as A9 in Abstract.md; formal models must verify all three links hold simultaneously under the relevant security assumptions.
Explicit initiator authentication: Alice's sender_sig is proof-of-possession of sk_IK_A. An adversary who knows only pk_IK_A cannot produce a valid sender_sig without sk_IK_A (HybridSign EUF-CMA, §3.3). Bob verifies the signature before any KEM operations (§5.5 Step 3), so a forged or missing signature is rejected immediately, not silently. Both identity keys are also committed into the HKDF info field — any substitution additionally fails at first-message decryption.
IMPORTANT — First-contact limitation (TOFU): The mutual authentication guarantee holds only when both parties possess authentic copies of each other's identity keys. The signature proves Alice holds sk_IK_A, but does not prove that pk_IK_A actually belongs to the human "Alice" — on first contact, Bob cannot verify the binding between pk_IK_A and a human identity.
A relay controlling the delivery path could substitute a different IK pair (its own pk_IK_X, sk_IK_X), forge a valid sender_sig, and impersonate Alice to Bob — because Bob has no reference key to compare against on first contact. This is trust-on-first-use (TOFU), identical to Signal, SSH, and all systems without centralized PKI. It is inherent, not a bug.
Mitigations:
- Verification phrases (§9) for post-hoc verification.
- Key pinning after first contact.
- Community server context (shared presence provides key distribution).
- Multi-path verification (compare keys from multiple independent servers).
KCI resistance: Corrupt(IK, A) enables impersonation of Alice (both signing pre-keys and forging sender_sig). Cannot impersonate Bob to Alice (requires Bob's SPK/OPK private keys, independent of sk_IK_A).
Non-deniability: LO-KEX does not provide deniability. Alice's sender_sig (§5.4 Step 6) is a HybridSign EUF-CMA signature over the encoded SessionInit — Bob can present (session_init_bytes, sender_sig, pk_IK_A) to any third party as cryptographic proof that Alice initiated this specific session. This is a deliberate departure from Signal's X3DH, which achieves deniability through DH's non-binding outputs (both parties can compute the same shared secret, so neither can prove who initiated). Systems requiring deniable authentication should note this property. See Appendix D (Hashimoto, PKC 2024) for post-quantum deniable AKE approaches.
Header integrity: AAD binds session init (§5.4 Step 7) and ratchet headers (§6.5). Header tampering → AEAD failure. See §7.3-7.4.
spk_id cryptographic binding: spk_id is not included in the HKDF info of KDF_KEX (§5.4) — its binding flows through a different path. spk_id is a field of SessionInit, which is encoded by encode_session_init (§7.4) and incorporated into the AEAD AAD for the first message (§5.4 Step 7 and §7.3). AEAD authentication over this AAD provides the cryptographic binding: any attacker who substitutes a different spk_id in transit causes the encode_session_init output to differ, which changes the AAD bytes, which causes AEAD authentication to fail on the responder's side. The binding chain is: spk_id → encode_session_init(session_init) → AEAD AAD → authentication tag. A formal modeler constructing a spk_id-substitution attack lemma should derive binding from this chain rather than from the KDF info path.
Channel 2 surface: LO-KEX exposes the following metadata to a passive network adversary: the bundle fetch event (party A intends to initiate a session with party B), the SessionInit message (reveals both fingerprints and crypto version to any interceptor), and failed initialization responses (a structural rejection is distinguishable from silence, enabling version and presence probing — see §1.5 for the probing implication). All content and authentication guarantees above are unaffected; these are structural metadata leaks outside the Channel 1 scope of this section.
6. LO-Ratchet
After session establishment, ongoing message encryption uses LO-Ratchet.
6.1 Overview
LO-Ratchet combines a KEM ratchet (replacing Double Ratchet's DH ratchet) with counter-mode message key derivation. When the conversation direction changes, the new sender generates a fresh X-Wing keypair, encapsulates to the other party's current ratchet public key, and derives new root and epoch keys. Within an epoch (between KEM ratchet steps), each message key is derived directly from the epoch key and the message counter in O(1), without sequential chain advancement.
Design rationale: The Signal Double Ratchet uses a sequential KDF chain that provides per-message forward secrecy — compromising the chain key at position N reveals only messages N+1, N+2, ... but not messages 0..N-1. LO-Ratchet deliberately trades this for per-epoch forward secrecy: compromising an epoch key reveals all messages in that epoch. This simplification eliminates the skip cache, TTL expiry, purge logic, and O(N) skip cost for out-of-order messages, removing the most error-prone component of the protocol. The practical security impact is minimal — the epoch key shares a memory region with strictly more powerful secrets (root key, ratchet secret key), and any realistic memory compromise that extracts the epoch key also extracts these adjacent secrets, rendering per-message forward secrecy moot. Scope note: this memory-colocation argument holds when all ratchet state resides in a single protected memory region. Architectures where only the epoch key is exported — for example, an HSM-backed ratchet that holds root_key and send_ratchet_sk in hardware but exports send_epoch_key to the application CPU for message key derivation — break the colocation assumption: an attacker who compromises only the exported epoch key does not automatically also hold root_key. In such architectures, the per-epoch vs. per-message trade-off carries real security cost and should be evaluated against the specific deployment threat model.
Channel 2 surface: The ratchet header (pk_s, c_ratchet, n, pn) is transmitted in cleartext and bound into the AEAD AAD but not encrypted. A passive network observer learns: when epoch transitions occur (from pk_s changes), whether a KEM ratchet step is present in this message (c_ratchet), the message's position within the current epoch (n), and the number of messages sent in the previous epoch (pn). Message content, epoch keys, and identity are fully protected; the header fields are structural metadata outside the Channel 1 scope of this section. See §1.5 for the full Channel 2 surface and transport-layer mitigations.
6.2 State
RatchetState = {
root_key: 32 bytes
send_epoch_key: 32 bytes
recv_epoch_key: 32 bytes
local_fp: 32 bytes // SHA3-256(full 3200-byte LO composite public key) for local party — NOT a sub-key hash, NOT the hex string
remote_fp: 32 bytes // SHA3-256(full 3200-byte LO composite public key) for remote party — same derivation rule
send_ratchet_sk: Option<X-Wing secret key> // None until first send; also serves as decapsulation key for incoming KEM ratchet steps (§6.6) — there is no separate recv_ratchet_sk. Stored as the 2432-byte expanded X-Wing secret key form (NOT the 32-byte seed) — see §6.8 guard 2; storing the seed form produces InvalidData on serialization.
send_ratchet_pk: Option<X-Wing public key> // None until first send. Dual role: (1) local state — the public key corresponding to send_ratchet_sk, included in outgoing message headers; (2) epoch routing anchor for the receiver — the receiver matches header.ratchet_pk against its own send_ratchet_pk (via recv_ratchet_pk on the other side) to identify the current epoch (§6.6)
recv_ratchet_pk: Option<X-Wing public key> // None for Alice until first recv
// Non-Rust reimplementer note: Option fields (recv_ratchet_pk, send_ratchet_sk/pk,
// prev_recv_epoch_key, prev_recv_ratchet_pk) use Rust's Option<T> type where None
// is semantically distinct from any byte pattern. In languages without sum types
// (C, Go), represent None with a separate boolean presence flag — do NOT use an
// all-zero array as a sentinel. An all-zero X-Wing public key is a valid (degenerate)
// key that would cause epoch routing in decrypt (§6.6) to match incorrectly.
prev_recv_epoch_key: Option<32 bytes> // Previous epoch key for late messages
prev_recv_ratchet_pk: Option<X-Wing public key> // Previous epoch ratchet public key
send_count: u32 // = header.n when sending; starts at 1 for Alice
recv_count: u32 // high-water mark: max(n+1) for current recv epoch
prev_send_count: u32 // = header.pn when sending
ratchet_pending: bool // set when peer KEM ciphertext received; cleared on next send
recv_seen: set of u32 // message counters successfully decrypted in current recv epoch
prev_recv_seen: set of u32 // message counters successfully decrypted in previous recv epoch
epoch: u64 // monotonic anti-rollback counter for serialization
}
send_ratchet_sk dual role
send_ratchet_sk serves two distinct purposes: (1) signing/encapsulating outgoing KEM ratchet steps, and (2) decapsulating incoming KEM ratchet ciphertexts. There is no separate recv_ratchet_sk. This design means the party who most recently sent a message holds the decapsulation key for the peer's next reply — the current sender's send key becomes the receiver's decapsulation target. A reimplementer who adds a separate recv_ratchet_sk field diverges from the state model and will fail on the first direction change.
Clarification on counter fields: send_count is the counter included as n in the ratchet header of outgoing messages. recv_count is the high-water mark for the current receive epoch: max(n + 1) across all successfully decrypted messages. prev_send_count is the value of send_count at the moment the KEM ratchet step fires, included as pn in the first message of a new send epoch. This is not the number of messages sent in that epoch — for Alice's first epoch, one message at n=1 advances send_count to 2, so pn=2 when the ratchet fires. These are the same values that appear in the wire format (Protocol Spec §12.9).
ratchet_pending flag: Set to true when a message is received that carries a new peer ratchet public key (triggering recv_ratchet_pk update). Cleared when the next encrypt() call performs the send-side KEM ratchet step. While ratchet_pending is true, any call to encrypt() will perform the ratchet step first. This defers the send-side ratchet until the party actually needs to send, rather than forcing it immediately on receipt. For Bob, ratchet_pending = true at initialization (§5.5 Step 7) — it is not exclusively a runtime transition flag. It means "a KEM ratchet step is required before the next send," which is true immediately after session establishment for the responder.
recv_seen set: Tracks which message counters have been successfully decrypted in the current receive epoch. Used for duplicate detection: a message with n already in recv_seen is rejected as DuplicateMessage. The set is bounded at MAX_RECV_SEEN = 65536 entries as defense-in-depth against memory exhaustion. The set resets on each KEM ratchet step. Required operations: O(1) average-case contains for per-message duplicate detection (called on every decrypt), and sorted ascending iteration at serialization time (§6.8 serializes recv_seen entries in ascending order). The data structure choice is an implementation concern — a hash set provides O(1) contains and requires a sort step at serialization; a sorted B-tree provides O(log n) contains and O(1) sorted iteration. Either satisfies the spec; the performance difference becomes meaningful only near MAX_RECV_SEEN = 65536 entries.
Previous epoch key: prev_recv_epoch_key holds the epoch key from the immediately preceding receive epoch, allowing decryption of late-arriving messages from that epoch. It is overwritten (and the old value zeroized) by the next KEM ratchet step — only one previous epoch key is retained at any time. prev_recv_ratchet_pk identifies which ratchet public key the previous epoch was associated with, enabling the receiver to route incoming messages to the correct epoch key.
Fingerprint immutability: local_fp and remote_fp are fixed at session initialization (init_alice/init_bob) and MUST NOT be modified for the session lifetime. Both values are embedded in every message's AAD — mid-session modification would silently corrupt AAD for all in-flight and future messages, producing permanent AeadFailed without a session reset. The library enforces this by storing the fingerprints inside RatchetState (not caller-supplied per call) and by rejecting mutations via the exclusive-access model (§6.2). In languages where state fields are publicly accessible, implementations MUST treat these fields as read-only after initialization.
Fingerprint derivation: local_fp and remote_fp are SHA3-256 of the full 3200-byte LO composite public key (X-Wing pk (1216 B) || Ed25519 pk (32 B) || ML-DSA-65 pk (1952 B)) — not a hash of any single sub-key, and not the hex string. The CAPI (soliton_ratchet_init_alice, soliton_ratchet_init_bob) accepts pre-computed 32-byte fingerprint bytes; the library cannot verify correct derivation. A mismatch produces AeadFailed on every message with no diagnostic — the fingerprints are embedded in AAD, so a wrong fingerprint fails authentication identically to a tampered ciphertext. Use soliton_identity_fingerprint (§13.4) to compute fingerprints from public key bytes.
Identity fingerprint invariant: local_fp and remote_fp must be distinct (local_fp ≠ remote_fp) and neither may be all-zero. Equal fingerprints would break AAD asymmetry, allowing a message encrypted by one party to be replayed as if sent by the other. All-zero fingerprints indicate uninitialized state. Both conditions are enforced at init_alice/init_bob (returning InvalidData) and at deserialization (guard 20). A state constructed with a zero fingerprint can encrypt/decrypt (the AEAD doesn't inspect fingerprint values), but fails to round-trip through serialization — enforcing at init prevents this latent inconsistency.
init_alice / init_bob function signatures and error returns:
function init_alice(
root_key: [u8; 32], // from KDF_KEX (§5.4 Step 4), secret material
epoch_key: [u8; 32], // from KDF_KEX (§5.4 Step 4), becomes send_epoch_key
local_fp: [u8; 32], // SHA3-256 of Alice's full 3200-byte public key
remote_fp: [u8; 32], // SHA3-256 of Bob's full 3200-byte public key
send_ratchet_pk: X-Wing public key (1216 bytes), // Alice's EK_pub (§5.4)
send_ratchet_sk: X-Wing secret key (2432 bytes), // Alice's EK_sk (§5.4)
) → RatchetState | InvalidData
function init_bob(
root_key: [u8; 32], // from KDF_KEX (§5.5 Step 4), secret material
epoch_key: [u8; 32], // from KDF_KEX (§5.5 Step 4), becomes recv_epoch_key
local_fp: [u8; 32], // SHA3-256 of Bob's full 3200-byte public key
remote_fp: [u8; 32], // SHA3-256 of Alice's full 3200-byte public key
recv_ratchet_pk: X-Wing public key (1216 bytes), // Alice's EK_pub (from SessionInit)
) → RatchetState | InvalidData
Parameter order note: Fingerprints follow root_key and chain_key but precede the ephemeral key parameters in both functions — (root_key, chain_key, local_fp, remote_fp, key_params...). The full CAPI signatures are soliton_ratchet_init_alice(root_key, root_key_len, chain_key, chain_key_len, local_fp, local_fp_len, remote_fp, remote_fp_len, ek_pk, ek_pk_len, ek_sk, ek_sk_len, out) and soliton_ratchet_init_bob(root_key, root_key_len, chain_key, chain_key_len, local_fp, local_fp_len, remote_fp, remote_fp_len, peer_ek, peer_ek_len, out). The §13.4 summary abbreviates for readability; fingerprints always follow root_key and chain_key but precede the ephemeral key parameters (ek_pk/ek_sk for Alice, peer_ek for Bob). A reimplementer who infers parameter order from the §13.4 abbreviation alone, or who follows an abbreviated listing that omits or reorders fingerprints, silently corrupts every message's AAD (the fingerprints flow into KDF_MsgKey for every message; wrong ordering produces wrong AAD, causing immediate AEAD failure). For init_alice, send_ratchet_pk appears before send_ratchet_sk (public key before secret key) — the reverse of the draft order common in academic specifications. Swapping pk/sk produces a type error in strongly-typed languages but not in C/Go/Python where both are *const u8.
init_alice and init_bob accept caller-supplied fingerprints — they are inputs, not outputs derived from the key material. The functions cannot verify that the fingerprints match the actual public keys used in the KEM exchange. They return InvalidData for: local_fp == remote_fp (AAD asymmetry violation), either fingerprint all-zero (uninitialized sentinel), root_key all-zero (liveness sentinel), or epoch_key all-zero (degenerate KEX output). On InvalidData, no ratchet handle is allocated.
Root key and epoch key liveness: The root_key and the input epoch_key parameter (from LO-KEX) must not be all-zero at init time. All-zero values indicate uninitialized or degenerate KEX output. This check applies to the epoch key that becomes the active direction's key — for Alice, send_epoch_key; for Bob, recv_epoch_key. The other direction's epoch key is intentionally set to all-zeros as a placeholder (Alice's recv_epoch_key, Bob's send_epoch_key) and is not checked at init — it will be set to a real value by the first KEM ratchet step in that direction. After a session-fatal error (encrypt AEAD failure), root_key is zeroized to zero — the all-zero liveness check on subsequent encrypt/decrypt calls prevents use of a dead session. Dual role of root_key: root_key serves two purposes: (1) it is the HKDF salt in KDF_Root (§6.4), providing forward secrecy by mixing fresh KEM shared secrets into the key hierarchy, and (2) it is the liveness sentinel checked at the top of encrypt/decrypt. Both uses require root_key to be secret material — the constant-time comparison in the liveness check (§6.5, §6.6) prevents timing side-channels that could leak root_key bytes.
Concurrency model: All operations on a RatchetState require exclusive access. No concurrent or reentrant calls are safe on the same state handle — even read-only queries (epoch, is-pending, etc.) must not race with encrypt/decrypt. The CAPI enforces this via an AtomicBool reentrancy guard (§13.6). Reimplementers wrapping the Rust core directly must provide their own mutual exclusion (e.g., Mutex<RatchetState>, not RwLock — encrypt, decrypt, and serialization may all trigger KEM ratchet steps or mutate counters). Exception: derive_call_keys (§6.12) takes &self and reads root_key without mutating any ratchet state. Multiple concurrent derive_call_keys calls are safe with respect to each other. However, derive_call_keys must still not race with encrypt/decrypt (which may advance root_key via a KEM ratchet step), so a RwLock — where derive_call_keys takes a read lock and encrypt/decrypt take write locks — is a valid alternative to Mutex for reimplementers who need concurrent call key derivation.
to_bytes() requires write-lock upgrade, not read-lock: to_bytes() is ownership-consuming (it takes self and nulls the handle on success — §6.8). In a RwLock scenario, to_bytes() requires a write lock with no outstanding readers — not a read lock. A reimplementer who acquires only a read lock for to_bytes() while a concurrent derive_call_keys also holds a read lock creates a use-after-consume race: both calls access the same handle, but to_bytes() destroys it. Rust's ownership system prevents this at compile time (consuming self requires &mut self upgrade, which is incompatible with any outstanding &self borrow). C/Go reimplementers using explicit RwLock primitives MUST acquire a write lock for to_bytes() and wait for all outstanding derive_call_keys read locks to drain before proceeding.
Anti-rollback epoch: The epoch counter starts at 0 and is incremented each time the state is serialized via to_bytes. On deserialization, the epoch must be strictly greater than the last-seen epoch for the same session. This prevents storage-layer replay of older blobs. See §6.8.
Initial state after LO-KEX:
For Alice (initiator):
send_ratchet_pk/sk= her EK (ephemeral key from §5.4 Step 2)recv_ratchet_pk=None(Bob hasn't sent yet)send_epoch_key= epoch key fromencrypt_first_messagerecv_epoch_key= all-zeros (unused until Bob sends)prev_recv_epoch_key=Nonesend_count= 1 (counter 0 was used by the random-nonce first message)recv_count= 0prev_send_count= 0 (initialization default — no KEM ratchet step has fired yet). When Alice's first KEM ratchet step fires,prev_send_countis set tosend_countat that moment (§6.4). If Alice sent one ratchet message (n=1, advancingsend_countto 2), her first ratchet message carriespn = 2, notpn = 0.ratchet_pending= falserecv_seen= empty,prev_recv_seen= emptyepoch= 0recv_seenis empty (not{0}) because counter 0 was consumed byencrypt_first_message— a structurally separate function fromencrypt(). A replayed session init is a protocol-layer concern (deduplicated by the relay), not a ratchet concern. Reimplementers MUST NOT seedrecv_seenwith{0}.
For Bob (responder):
recv_ratchet_pk= Alice's EK_pub (from session init)send_ratchet_pk/sk=None(set on first send)recv_epoch_key= epoch key fromdecrypt_first_messagesend_epoch_key= all-zeros (unused until Bob sends)prev_recv_epoch_key=Nonerecv_count= 1 (counter 0 was consumed by the session-init message, outside the ratchet). Derivation:recv_counttracksmax(n + 1)across successfully decrypted messages in the current epoch;decrypt_first_messageprocesses the message at counter 0, producingmax(0 + 1) = 1. This is a bookkeeping value for serialization consistency (guard 17 requiresrecv_seenentries to be< recv_count), not a replay guard — Alice'ssend_countstarts at 1, so she will never produce a ratchet message withn = 0in this epoch. A reimplementer who treatsrecv_count = 1as the security control preventingn = 0replays is relying on a false assumption; the actual protection is Alice'ssend_countstarting at 1 (§5.4 Step 7).send_count= 0prev_send_count= 0 (first ratchet message from Bob carriespn = 0— no prior send epoch exists)ratchet_pending= true (Bob must ratchet before first send)recv_seen= empty,prev_recv_seen= emptyepoch= 0recv_seenis empty (not{0}) for the same reason as Alice: counter 0 was consumed bydecrypt_first_message, outside the ratchet. Reimplementers MUST NOT seedrecv_seenwith{0}— doing so causes the first ratchet-layer message (counter 0 in a new epoch after Bob's KEM ratchet step) to be rejected asDuplicateMessage.
6.3 Counter-Mode Message Key Derivation
function KDF_MsgKey(epoch_key, counter):
return HMAC-SHA3-256(key=epoch_key, data=0x01 || big_endian_32(counter))
Each counter value produces a unique message key from the static epoch key. The 0x01 prefix byte provides domain separation — no other derivation from the epoch key currently exists, but the prefix reserves the 0x01 domain for message keys, leaving other prefix values available for future epoch-key-derived outputs without risking collision. The epoch key does not advance per message — it is fixed for the duration of an epoch (between KEM ratchet steps).
Counter=0 cannot collide with any chain-advancement derivation: In this counter-mode design there is no chain key advancement step — KDF_Chain does not exist. Counter 0 is simply KDF_MsgKey(epoch_key, 0), and counters 1, 2, … are independent derivations from the same static key. There is no internal computation that uses the same HMAC key and input as counter 0 for any other purpose. The 0x01 domain prefix ensures that even if a hypothetical future protocol extension derived something from the epoch key with a different prefix byte, counter 0 (data = 0x01 || 0x00000000) would not collide with it. A reimplementer coming from a chain-ratchet background (e.g., Signal's Double Ratchet) should note: there is no KDF_Chain(epoch_key) producing (msg_key, next_chain_key) — the epoch key is never used as input to derive another epoch key; that transition happens only via KDF_Root on a KEM ratchet step.
HMAC-SHA3-256 block size is 136 bytes: SHA3-256's rate (block size) is 136 bytes, not the 64-byte SHA-2 block size most developers have internalized. RFC 2104 HMAC pads/hashes the key to the hash's block size. A reimplementer building HMAC from a raw SHA3-256 primitive who assumes 64-byte blocks produces wrong padding and silently incorrect MACs. Standard HMAC libraries handle this automatically — this note exists for anyone implementing HMAC from scratch.
HMAC-SHA3-256 uses FIPS 202 SHA3-256, not Keccak-256: The SHA3-256 here is the NIST-standardized variant (FIPS 202, domain-separation suffix 0x06), not the pre-standardization Keccak-256 used in Ethereum and similar systems (suffix 0x01). The two produce different outputs for the same input. Both have the same 136-byte block size, so the block-size hazard above does not detect this substitution — the HMAC library silently accepts either hash function and produces wrong but plausible-looking output. In Go, use sha3.New256() from golang.org/x/crypto/sha3, not sha3.NewLegacyKeccak256(). In Python, use hashlib.sha3_256, not a pysha3 or pycryptodome Keccak binding. Every message key, root key, and call chain key derivation in this protocol would be wrong throughout if Keccak-256 were substituted here.
HMAC input is exactly 5 bytes: no length prefix, no padding. The data field is the literal concatenation 0x01 || big_endian_32(counter) — 1 byte domain prefix followed by 4 bytes counter. Unlike the HKDF info fields in §5.4 (which use 2-byte BE length prefixes), HMAC data here is a fixed-layout input with no framing. A reimplementer who adds a 2-byte length prefix (e.g., 0x00 0x05 || 0x01 || counter) by analogy with HKDF conventions produces a different 32-byte message key with no error or diagnostic.
HMAC domain byte allocation (complete registry — protocol extenders MUST NOT reuse allocated values):
| Byte | Use | Section |
|---|---|---|
0x01 |
Message key derivation (KDF_MsgKey) |
§6.3 |
0x02-0x03 |
Reserved | — |
0x04 |
Call key_a derivation (AdvanceCallChain) |
§6.12 |
0x05 |
Call key_b derivation (AdvanceCallChain) |
§6.12 |
0x06 |
Call chain_key derivation (AdvanceCallChain) |
§6.12 |
Note: 0x04-0x06 operate on the call chain key (§6.12), not the epoch key. They are listed here for completeness — the domain byte space is global across all single-byte HMAC-SHA3-256 data inputs in the protocol.
HMAC is used here (not HKDF) as a PRF — each call is independent with a fixed key and unique counter input. No extract phase is needed because the epoch key is already uniformly distributed (output of HKDF in KDF_Root). For formal models: treat as PRF(ek, 0x01 ‖ BE32(counter)).
Forward secrecy is per-epoch, not per-message. Compromising an epoch key reveals all message keys in that epoch. Forward secrecy across epochs is provided by the KEM ratchet (§6.4), which derives each new epoch key from a fresh KEM shared secret via KDF_Root. See §6.13 for the design rationale.
The output is wrapped in Zeroizing to ensure automatic memory wipe after use. Non-Rust implementations: the 32-byte msg_key returned by KDF_MsgKey is secret key material — it MUST be zeroized immediately after AEAD encryption or decryption completes. In C/Go/Java/Python, the caller must explicitly call memset_s (C), Arrays.fill (Java), or equivalent after use. RAII-less environments MUST NOT rely on garbage collection or variable scope for zeroization — the key may remain in memory until a future allocation overwrites it, which can be arbitrarily delayed. The zeroization MUST occur before any error path or early return that could skip cleanup (e.g., if AEAD fails after key derivation but before the key is used, the derived key must still be zeroized).
6.4 KEM Ratchet Step
When sending and send_ratchet_pk is absent or ratchet_pending is true:
function PerformKEMRatchetSend(state):
peer_pk = state.recv_ratchet_pk // must be Some; if None → Internal error
// (structurally unreachable from valid state — deserialization guards 6
// and 9 in §6.8 prevent this configuration, and init_bob always sets it)
// Generate new ratchet keypair.
(new_pk, new_sk) = XWing.KeyGen()
// Encapsulate to peer's ratchet public key.
(ct, ss) = XWing.Encaps(peer_pk)
// Advance root key, derive new send epoch key.
(state.root_key, state.send_epoch_key) = KDF_Root(state.root_key, ss)
// Update state.
state.send_ratchet_sk = new_sk // old sk auto-zeroized via ZeroizeOnDrop (Rust only).
// Non-Rust reimplementers MUST explicitly zeroize the
// old send_ratchet_sk before overwriting: assigning a new
// pointer or value leaves the old key bytes on the heap.
// When send_ratchet_sk is None (Bob's first send, or Alice's
// state before any send), the zeroization obligation is
// vacuously satisfied — there are no key bytes to zeroize.
// In C: check for null before calling memset; in Go: check
// for nil slice. The obligation applies only when transitioning
// from Some(old_sk) to Some(new_sk).
// Same obligation applies to prev_recv_epoch_key rotation
// in §6.6 (old recv_epoch_key becomes prev_recv_epoch_key;
// if prev_recv_epoch_key is being replaced, zeroize before
// overwriting the slot).
//
// CRITICAL: the old send_ratchet_sk MUST NOT be zeroized
// until after all three preceding operations (KeyGen, Encaps,
// KDF_Root) have completed successfully. All three are
// fallible (CSPRNG failure, structural error). If any fails,
// the ratchet step must abort with no state change — the
// caller must be able to retry with the session in its
// pre-ratchet state. The reference implementation guarantees
// this by performing all state writes (this line and below)
// only after the fallible operations return successfully.
// A reimplementer who "eagerly" zeroizes the old sk
// immediately after keygen (before Encaps) loses the ability
// to roll back on Encaps failure, leaving the session
// permanently without a valid send ratchet key.
state.send_ratchet_pk = new_pk
state.prev_send_count = state.send_count // MUST precede send_count = 0 (see below)
state.send_count = 0 // Post-ratchet epochs start at n=0; Alice's first epoch
// is the exception (send_count=1 from session init, §5.4 Step 7).
// Reset (not continuation) is safe: the new epoch_key is independent
// (derived from a fresh KEM shared secret via KDF_Root), so counter N
// under epoch E₁ and counter N under epoch E₂ produce different
// message keys. Continuation would also be correct but reset is
// the simpler invariant and matches header.n expectations.
// **All seven field writes are atomic — no serialization point may be
// introduced between the start of KDF_Root and the post-AEAD send_count
// += 1.** This covers the entire sequence: `KDF_Root` (writes root_key
// and send_epoch_key), `send_ratchet_sk = new_sk`, `send_ratchet_pk =
// new_pk`, `prev_send_count = send_count`, `send_count = 0`,
// `ratchet_pending = false`. If serialization occurs after KDF_Root
// updates root_key/send_epoch_key but before send_count resets to 0,
// the blob encodes new epoch keys with the old send_count. Guard 8 does
// NOT catch this intermediate: `send_count > 0` with `send_ratchet_sk`
// present is a valid combination (every non-initial send), so the blob
// reloads successfully — but the session silently derives nonces using
// a desynchronized counter, causing AEAD failure against the peer.
// **Guard 8 transient violation (§6.8 guard 8)**: After `send_count = 0`
// specifically, the state has send_count == 0 with send_ratchet_sk
// present — the exact combination guard 8 rejects at deserialization.
// This narrower window is safe only because encrypt() holds exclusive
// access (§6.2) and does not yield between this line and send_count += 1.
// The full atomicity requirement above is the stronger invariant.
state.ratchet_pending = false
zeroize(ss) // ss MUST be zeroized AFTER KDF_Root completes — it is the IKM input
// to HKDF and must survive until KDF_Root returns. Zeroizing ss before
// KDF_Root would use all-zero IKM, silently producing a weak, predictable
// epoch key. zeroize(ss) MUST be positioned after both state.root_key and
// state.send_epoch_key are written. Non-Rust implementations MUST NOT
// reorder or "optimize" this zeroization earlier.
return ct // Included in message header
prev_send_count = send_count MUST precede send_count = 0 — correctness requirement, not incidental ordering: The two assignments appear in fixed order in the pseudocode, but the ordering is a hard correctness requirement. prev_send_count captures the count of messages sent in the just-completed epoch, which is transmitted as pn in ratchet-step headers so the peer knows how many messages to expect from the old epoch. If send_count = 0 executes first, prev_send_count captures the reset value (0) regardless of how many messages were sent — every subsequent ratchet-step header carries pn = 0. The peer's AEAD succeeds (pn is AAD-bound but both sides compute the same wrong AAD when both use pn = 0), so this failure is silent in same-implementation testing. It only manifests as AeadFailed when a reimplementer's peer uses the correct ordering. A simple field swap in the implementation or a refactoring that moves the reset earlier silently introduces this bug.
Root KDF:
function KDF_Root(root_key, kem_shared_secret):
output = HKDF(
salt = root_key,
ikm = kem_shared_secret, // 32 bytes — the full X-Wing combiner output (SHA3-256 of
// ss_M ‖ ss_X ‖ ct_X ‖ pk_X ‖ XWingLabel, §8.2) — NOT the
// X25519 DH output (ss_X) or ML-KEM shared secret (ss_M) alone
info = "lo-ratchet-v1", // raw 13-byte UTF-8 — no length prefix (unlike §5.4 KDF_KEX info which uses len(x)||x per field)
len = 64
)
return (output[0..32], output[32..64]) // (new_root, new_epoch_key)
// bytes [0..32] → new root_key (replaces state.root_key)
// bytes [32..64] → new epoch_key (becomes state.send_epoch_key or state.recv_epoch_key)
// Swapping the two halves is a silent wrong-output failure:
// AEAD succeeds on the sender because both sender and receiver
// share the same wrong key, but the root key evolves along a
// different trajectory than the spec, breaking interoperability
// with any correct implementation. F.2 (§Appendix F) provides
// labeled vectors for both output halves.
Why root_key is HKDF salt (not IKM): Placing the existing chain state (root_key) as the HKDF salt means the extraction phase is keyed by accumulated entropy from all prior KEM ratchet steps. Even a weak kem_shared_secret (e.g., from a biased KEM or compromised randomness) cannot dominate the extraction — the pre-existing root entropy conditions the PRK. This follows Signal's ratchet KDF design. By contrast, KDF_KEX (§5.4 Step 4) uses a zero salt because there is no prior chain state at session establishment — the zero salt is the RFC 5869 §2.2 default for "no prior keying material."
KDF_Root is infallible in the reference implementation: HKDF-Expand output length is bounded by 255 × HashLen (RFC 5869 §2.3). For SHA3-256 (HashLen = 32), the maximum is 255 × 32 = 8160 bytes. KDF_Root requests exactly 64 bytes, which is well within this limit. The operation therefore cannot fail due to output-length overflow in a correct SHA3-256 HKDF implementation. Reimplementers using a fallible HKDF API (e.g., returning an error for length > max) will never observe that error on this call path; if they do, it indicates an implementation bug and MUST be treated as Internal, not surfaced to callers.
6.5 Message Encryption
function Encrypt(state, plaintext, sender_fp, recipient_fp):
// Liveness guard: all-zero root_key indicates a dead (post-reset) session.
// Constant-time comparison — root_key is secret material.
if root_key == 0x00{32}:
raise InvalidData
// Guard against nonce reuse before any mutation.
// Idempotent: repeated calls with send_count at u32::MAX return ChainExhausted
// without modifying state — no progressive corruption on retry.
if state.send_count == u32::MAX:
raise ChainExhausted
// Terminal state: when send_count == u32::MAX AND ratchet_pending == true,
// the pending KEM ratchet step (which would reset send_count to 0) never
// fires — the ChainExhausted guard blocks before the ratchet_pending check.
// The session is permanently un-sendable. Full session reset (§6.10) and
// new LO-KEX exchange required. A reimplementer who assumes the pending
// ratchet "unblocks" the guard will deadlock silently.
// Perform ratchet if needed (no send chain yet, or direction changed).
// These are two independent conditions, NOT interchangeable:
// - send_ratchet_pk is None: Bob's initial state (never sent) — both conditions true
// - ratchet_pending: direction changed since last send — send_ratchet_pk is Some
// After the first KEM ratchet step, send_ratchet_pk is always Some; only
// ratchet_pending toggles. Collapsing them into a single flag breaks Bob's first send.
kem_ct = None
if state.send_ratchet_pk is None or state.ratchet_pending:
kem_ct = PerformKEMRatchetSend(state)
msg_key = KDF_MsgKey(state.send_epoch_key, state.send_count)
nonce = 0x00{20} || big_endian_32(state.send_count)
// SAME counter: KDF_MsgKey and nonce derivation both use state.send_count.
// These are NOT separate counters. A reimplementer who uses a separate
// nonce_counter (drifting from send_count) breaks AEAD authentication: the
// receiver derives msg_key from header.n but constructs the nonce from header.n
// as well — both use the same wire value. If the sender's nonce_counter diverges
// from send_count, the nonce used for encryption differs from what the receiver
// expects, producing AeadFailed with no diagnostic. If nonce_counter eventually
// aliases send_count at a prior value, nonce reuse follows.
header = {
ratchet_pk: state.send_ratchet_pk,
kem_ct: kem_ct,
n: state.send_count,
pn: state.prev_send_count
}
header_bytes = encode_ratchet_header(header)
// sender_fp = local party's fingerprint, recipient_fp = remote party's fingerprint.
// These are reversed on the decrypt side (§6.6) where sender_fp = remote.
aad = "lo-dm-v1" || sender_fp (32 B) || recipient_fp (32 B) || header_bytes
ciphertext = AEAD(msg_key, nonce, plaintext, aad)
if ciphertext is Error:
// Session-fatal: zeroize all key material to prevent nonce reuse on retry.
reset(state)
zeroize(msg_key)
raise AeadFailed
state.send_count += 1
zeroize(msg_key)
return (header, ciphertext)
The nonce encodes send_count in the last 4 bytes of a 24-byte buffer (bytes 0-19 are zero). Each (msg_key, nonce) pair is unique because the counter is distinct per epoch position. When send_count = 0 (the first message of a post-ratchet epoch — e.g., Bob's very first send after ratchet_pending clears), this produces a 24-byte all-zero nonce. This is safe and expected: the epoch key is fresh from KDF_Root, so the (epoch_key, nonce) pair is unique globally even though the nonce bytes are all zero. Implementations MUST NOT add a defensive guard that rejects all-zero nonces — doing so breaks every first post-ratchet message.
Encrypt atomicity: Unlike decrypt (§6.6), encrypt does not use snapshot/rollback. Atomicity is achieved through ordering — all fallible operations (ChainExhausted check, KEM keygen/encapsulate/KDF_Root, KDF_MsgKey, AEAD) execute before send_count is incremented. If any pre-AEAD operation fails, no state has been mutated. The KEM ratchet step (§6.4) is the exception: it mutates root_key, send_epoch_key, send_ratchet_sk/pk, prev_send_count, send_count, and ratchet_pending before AEAD runs. If AEAD fails after a successful KEM ratchet step, these mutations cannot be safely unwound, so the session is zeroized via reset() (see below). A reimplementer MUST NOT mutate send_count optimistically before AEAD succeeds — doing so would consume a counter on failure, eventually causing nonce reuse.
Cooperative multitasking within the atomicity window: The "exclusive access" model (§6.2) prevents concurrent calls on the same ratchet state, but it does not prevent the owning coroutine or goroutine from yielding during an encrypt call. In async/await (Rust, Python, JavaScript), goroutines (Go), or green threads (Erlang, Ruby Fiber), a yield between PerformKEMRatchetSend (which mutates root_key, send_epoch_key, etc.) and send_count += 1 causes the serialized state — produced by any to_bytes() call on that yield point — to encode the post-ratchet mutated fields alongside the pre-increment send_count. Guard 8 (§6.8, ratchet_pending requires recv_ratchet_pk) does not fire here, but the re-loaded session will have mismatched ratchet state: the new send_epoch_key with the old send_count, causing nonce reuse on the next post-load encrypt. The mitigation: the to_bytes call MUST NOT happen within the encrypt call's atomicity window. Callers MUST either hold the ratchet under a mutex that covers the full encrypt call (not just the state mutation), or structure async code so that to_bytes is never called in a concurrent task on the same ratchet state. Rust's &mut self on encrypt prevents this by construction (a mutable reference cannot be aliased); Go/Python/C callers must manage this explicitly.
PerformKEMRatchetSend ordering is a correctness requirement: The retry guarantee — that Internal (CSPRNG failure) from encrypt is safe to retry because no state was mutated — holds only if PerformKEMRatchetSend completes all fallible operations (keygen, encapsulate, KDF_Root) before writing any field. The pseudocode preserves this: XWing.KeyGen() and XWing.Encaps() both complete before any of the seven fields are written (line: (new_pk, new_sk) = XWing.KeyGen() then (ct, ss) = XWing.Encaps() then all state writes). A reimplementer who interleaves field mutations with fallible operations — for example, storing the new ratchet keypair immediately after keygen but before encapsulate — loses the retry guarantee and must implement explicit snapshot/rollback for the interleaved fields.
Encrypt atomicity is a documentation-only guarantee, not a structural one: The decrypt path enforces rollback integrity via an explicit save_recv_state / restore_recv_state snapshot, making the invariant structurally visible. The encrypt path has no equivalent snapshot mechanism — its safety guarantee is maintained solely by the ordering of operations in the pseudocode and implementation. A future refactor that reorders operations (e.g., moving send_count += 1 earlier, or splitting PerformKEMRatchetSend across multiple steps with interleaved state mutations) would silently break the retry and nonce-reuse guarantees with no compile-time or runtime protection. Security reviewers auditing the implementation should verify the operation order explicitly, and any refactor touching the encrypt path MUST maintain: (1) all XWing.KeyGen()/XWing.Encaps() calls complete before any state field is written; (2) send_count is incremented only after AEAD succeeds; (3) if AEAD fails after any state mutation, reset() is called before returning AeadFailed.
Internal from encrypt: When ratchet_pending = true, encrypt() calls XWing.KeyGen() and XWing.Encaps() as part of the KEM ratchet step (§6.4). Both operations consume CSPRNG randomness; CSPRNG exhaustion (structurally unreachable on standard OSes, but possible on embedded targets or under sandbox misconfiguration) propagates as Internal. This failure occurs before any state mutation — no KEM ratchet step has been applied, no counter has been consumed, and the session is unchanged. The call is safe to retry. ratchet_pending retains its pre-call value (true) — the next encrypt() call re-enters PerformKEMRatchetSend automatically without any caller intervention. The caller MUST NOT manually clear or re-set ratchet_pending after an Internal error. The documented encrypt error table is: ChainExhausted (counter at limit), AeadFailed (session-fatal, see below), and Internal. Internal has two sources with different retry semantics: (1) CSPRNG failure in XWing.KeyGen() / XWing.Encaps() — occurs before any state mutation, retryable; (2) recv_ratchet_pk = None inside PerformKEMRatchetSend — the KEM ratchet step requires the peer's last-received ratchet public key, which is absent only if the ratchet was deserialized from a structurally invalid blob. This variant is not retryable — the session is structurally inconsistent and must be reset. Callers who treat all Internal returns from encrypt as retryable will loop indefinitely on the second variant. See §6.6 for the analogous decrypt error table.
Session-fatal encrypt failure: AEAD encryption failure is treated as session-fatal — all session key material (root key, send/receive epoch keys, ratchet keys) is zeroized, making the state permanently unusable. The fingerprints (local_fp, remote_fp) are also zeroized as part of reset() — see §6.10 for the caller obligation to preserve fingerprints independently before reset. The caller must discard the session. The send counter is only incremented on success (after AEAD encryption), so AEAD failure does not consume a counter. The defense-in-depth zeroization prevents any possibility of nonce reuse from retry attempts. In practice, XChaCha20-Poly1305 encrypt only fails on integer overflow (plaintext.len() ≈ usize::MAX) — which does not occur with well-formed input. The liveness guard (§6.2) is how this achieves permanent unusability without a separate is_dead flag: reset() zeros root_key, and subsequent encrypt/decrypt calls detect the all-zero root key via constant-time comparison and return InvalidData.
Pseudocode parameters vs. API: sender_fp and recipient_fp are shown as explicit parameters in the pseudocode for clarity of AAD construction. In the actual API, they are stored in RatchetState at init_alice/init_bob time (as local_fp/remote_fp) and are not caller-supplied per call. The Rust signature is encrypt(&mut self, plaintext: &[u8]) — no fingerprint parameters. The CAPI soliton_ratchet_encrypt similarly takes no fingerprint parameters. A reimplementer who exposes per-call fingerprint parameters allows callers to pass different fingerprints on different calls, weakening the AAD binding guarantee.
Dropped encrypt results orphan counter slots: A successful encrypt() call advances send_count irrevocably. If the caller discards the returned (header, ciphertext) without transmitting it (e.g., due to a transport-layer error after AEAD succeeded), the counter slot is permanently consumed. The receiver will never see a message at that counter — counter-mode is tolerant of holes, so no error occurs on the receiver side. However, the caller MUST NOT re-encrypt the same plaintext on transport failure: a second encrypt() call uses the next counter, not the one that was dropped. The Rust API marks encrypt() with #[must_use], producing a compiler warning if the result is discarded. The CAPI soliton_ratchet_encrypt carries __attribute__((warn_unused_result)) in the generated header, providing the same compiler-level signal in C and C++. Languages without this feature (Go, Python) must enforce this obligation via documentation and caller discipline. Retry-loop hazard: a caller who, on transport failure, calls encrypt() again to "retransmit" is producing a new message at a new counter — not a retransmission of the original. The recipient will receive two messages at two different counter values. If the original message is ever delivered, no replay detection fires (both counters are distinct), and both messages are accepted. To retransmit, the caller must buffer and resend the already-encrypted (header, ciphertext) output, not invoke encrypt() again.
Counter gaps are a normal protocol property: The receiver MUST NOT treat counter gaps (missing entries in the n sequence) as errors. Gaps caused by dropped encrypt results are indistinguishable from gaps caused by lost network packets — both appear as missing entries in the counter sequence, and neither leaves any trace in the receiver's state. An application that uses counter gaps for application-layer loss detection, or a reimplementer who adds receiver-side gap-rejection logic, would break the protocol for any transport with unreliable delivery.
Critical: The AAD includes the canonical encoding of the full ratchet header. This prevents an attacker from:
- Substituting
ratchet_pk(would poison recipient's ratchet state). - Modifying
pn(would cause incorrect previous-epoch counter range → state desync or forced message loss). - Injecting a fake
kem_ct(would corrupt root key derivation).
6.6 Message Decryption
Helper function definitions used in the pseudocode below:
-
save_recv_state(state) → snapshot: Captures all receive-side state fields:recv_epoch_key,recv_count,recv_ratchet_pk,prev_recv_epoch_key,prev_recv_ratchet_pk,recv_seen,prev_recv_seen,root_key, andratchet_pending.recv_countmust be included because the new-epoch path sets it to 0 (line 1345) before AEAD runs — a failed AEAD would leaverecv_countzeroed unless the snapshot captures and restores it. Does NOT capture send-side state (send_epoch_key,send_ratchet_sk,send_ratchet_pk,send_count,prev_send_count) — those are mutated only byencrypt()and are not part of the decrypt rollback scope.epochis NOT in the snapshot and is NOT mutated bydecrypt()—epochis a serialization counter incremented only byto_bytes()(§6.7) to version the stored blob; it plays no role in the cryptographic operations ofdecrypt()and does not appear in any message or AAD. A reimplementer who includesepochin the snapshot or who incrementsepochinsidedecrypt()would desync the blob version counter from the actual serialize-call count, causingUnsupportedVersionerrors on reload after a decrypt that was followed by no serialize. -
restore_recv_state(state, snapshot): Restores all fields captured bysave_recv_state, reverting any decrypt-path state mutations. Called on any failure after the snapshot is taken. A no-op in terms of effect if no mutations occurred before the failure. -
Epoch identification: The
current_epochandprev_epochboolean assignments in the pseudocode below ARE the epoch identification step. The reference implementation extracts this routing logic into a privateidentify_epoch()helper method; the pseudocode inlines it for presentation clarity. Comments in the pseudocode that referenceidentify_epoch()describe this inline routing block.
function Decrypt(state, header, ciphertext, sender_fp, recipient_fp):
// Liveness guard: all-zero root_key indicates a dead (post-reset) session.
// Constant-time comparison — root_key is secret material.
if root_key == 0x00{32}:
raise InvalidData
// Counter exhaustion guard — BEFORE any KEM ratchet step.
// recv_count is updated as max(recv_count, n + 1): with n = u32::MAX,
// n + 1 wraps to 0 in unsigned arithmetic, silently resetting the
// high-water mark and making all previously-seen counters appear unseen
// (replay window collapse). Placing this before epoch-specific logic
// prevents any cryptographic state mutation. NOTE: in this pseudocode,
// the snapshot is allocated below (at `save_recv_state`, after
// `identify_epoch()` and the pre-mutation structural checks). The
// reference implementation allocates the snapshot before this guard —
// in Rust the guard fires after the snapshot is already taken. Both
// orderings are correct since no state mutations precede this guard;
// rollback is a no-op either way. See Appendix E for the failure table
// entry that documents both orderings.
// ChainExhausted (not InvalidData) mirrors the send-side guard (§6.5):
// the counter space is exhausted, not a structural format error.
if header.n == u32::MAX:
raise ChainExhausted
// Identify which epoch this message belongs to.
// Epoch routing depends solely on header.ratchet_pk — the presence or
// absence of header.kem_ct is NOT examined until the new-epoch path is
// confirmed. The three cases are evaluated in priority order (if/else if/else),
// not as independent predicates.
// IMPLEMENTATION REQUIREMENT: Implementations MUST NOT add a pre-AEAD structural
// check that rejects current-epoch or previous-epoch messages carrying a kem_ct.
// A message that matches the current or previous epoch MAY carry a kem_ct field
// (e.g., a retransmitted ratchet-step message replayed with different routing).
// Rejecting such a message as `InvalidData` before AEAD runs would violate the
// oracle-collapse requirement (§12): `InvalidData` arrives in nanoseconds while
// AEAD takes microseconds, creating a timing oracle that distinguishes "has kem_ct
// in wrong context" from "authentication failed." The kem_ct is simply ignored on
// non-new-epoch paths; AEAD authentication provides the correct rejection if the
// message is invalid for any reason. A reimplementer adding a "kem_ct MUST be
// absent for same-epoch messages" guard MUST ensure it is collapsed to `AeadFailed`
// (not returned as `InvalidData`) if they choose to add it.
// CONSTANT-TIME REQUIREMENT: Each comparison that is actually executed MUST
// use constant-time equality (e.g., subtle::ConstantTimeEq). The risk is NOT
// leaking which epoch a message belongs to — header.ratchet_pk is cleartext,
// so the epoch type is already publicly observable. The actual risk is leaking
// the byte values of the stored recv_ratchet_pk or prev_recv_ratchet_pk via
// a timing side-channel: a crafted probe message with a hand-crafted ratchet_pk
// that shares a prefix with the stored key can measure whether a partial match
// shortens or lengthens the comparison, recovering the stored key byte-by-byte.
// "Both comparisons" is an approximation — the implementation evaluates
// prev_epoch first and short-circuits (early return) if it matches; current_epoch
// is only reached if prev_epoch is false. Each comparison that EXECUTES must be
// constant-time; which comparisons execute depends on state.
// See Appendix E.
current_epoch = (state.recv_ratchet_pk is Some AND
header.ratchet_pk == state.recv_ratchet_pk)
prev_epoch = (state.prev_recv_ratchet_pk is Some AND
header.ratchet_pk == state.prev_recv_ratchet_pk)
// When recv_ratchet_pk is None (Alice's initial state, before any receive),
// current_epoch is always false (None ≠ any public key) and prev_epoch is
// always false (prev_recv_ratchet_pk is also None). Every incoming message
// takes the new-epoch KEM ratchet path until the first successful decrypt
// establishes recv_ratchet_pk. Implementations in languages without native
// option types (C, Go) must represent None as a distinct sentinel — not
// all-zeros — and explicitly return false for both epoch checks.
//
// **Why `current_epoch` also requires the `is Some` guard**: Both predicates
// are structurally symmetric. Without the guard, a language using an all-zero
// sentinel for "absent" public key would evaluate `header.ratchet_pk == 0x00{1216}`
// as true whenever the header carries an all-zero ratchet_pk — routing the message
// to the current-epoch path even though no current-epoch key has been established.
// The guard closes this: if `recv_ratchet_pk is None`, `current_epoch` is false
// regardless of the header's ratchet_pk value, and the message correctly takes
// the new-epoch KEM ratchet path. Rust handles this naturally via `Option<T>`;
// C/Go/Python implementations MUST use a distinct non-zero sentinel or an explicit
// boolean "is_set" flag, NOT an all-zeros value, to represent the absent state.
// Structural check: previous-epoch messages require a retained epoch key.
if prev_epoch AND NOT current_epoch AND state.prev_recv_epoch_key is None:
raise InvalidData
// Snapshot all receive-side state for rollback on any failure.
// (see "State rollback on failure" below)
snapshot = save_recv_state(state)
// --- Epoch-specific key derivation (priority matching) ---
// Previous-epoch check takes priority over current-epoch: this is
// a correctness requirement, not a tie-breaker for a rare edge case.
// Without this ordering, a message matching both predicates (possible
// in certain initial-state configurations where prev_recv_ratchet_pk
// == recv_ratchet_pk) would be routed nondeterministically.
// This configuration is unreachable through honest operation — the
// KEM ratchet always rotates old → prev before writing new → current,
// so the two keys are always distinct. The priority is a correctness
// invariant against crafted or corrupted blobs, not a common case
// requiring special handling.
// See Abstract.md §5.4 for formal justification.
if prev_epoch:
msg_key = KDF_MsgKey(state.prev_recv_epoch_key, header.n)
else if NOT current_epoch:
// New epoch: KEM ratchet step.
kem_ct = header.kem_ct // must be present; absent → InvalidData
if state.send_ratchet_sk is None:
raise InvalidData
// send_ratchet_sk is None when the party has never sent (e.g.,
// Bob's initial state before his first encrypt). A forged or
// misrouted message with an unrecognized ratchet_pk triggers
// the new-epoch path against this state. Without this guard,
// a reimplementer might unwrap None/null or silently use a
// zero key instead of returning an error.
// Decapsulate with send_ratchet_sk: the sender encapsulated to our
// send_ratchet_pk (which we published in our last outgoing header),
// so the matching secret key is send_ratchet_sk, not recv_ratchet_sk.
ss = XWing.Decaps(state.send_ratchet_sk, kem_ct)
// In lo-crypto-v1, XWing.Decaps never fails cryptographically — ML-KEM
// uses implicit rejection (§8.4) and X25519 always produces a 32-byte
// result. A structural DecapsulationFailed (wrong kem_ct length) is
// reachable if the ciphertext is malformed. Both DecapsulationFailed
// and AeadFailed trigger the same snapshot rollback: restore_recv_state
// before returning. No state mutations have occurred yet at this point —
// epoch rotation (saving previous epoch keys, overwriting recv_epoch_key,
// resetting recv_count, clearing recv_seen) follows below. Rollback is
// applied unconditionally by the snapshot-and-restore mechanism regardless.
// DecapsulationFailed on the decrypt path is NOT session-fatal — unlike
// AeadFailed on the encrypt path (§6.5), which zeroizes all key material.
// The snapshot rollback restores the session to its pre-call state,
// and the caller can retry or discard the message. Treating decrypt-side
// DecapsulationFailed as session-fatal (by analogy with encrypt-side
// AeadFailed) would incorrectly terminate sessions on malformed messages.
// Rotate previous epoch: current becomes previous.
// Only save the previous epoch if recv_ratchet_pk was set (meaningful
// current epoch exists). On the first KEM ratchet (Alice's init state),
// recv_ratchet_pk is None — there is no previous epoch to save.
if state.recv_ratchet_pk is Some:
state.prev_recv_epoch_key = state.recv_epoch_key
// ZEROIZATION NOTE: after this assignment, `state.prev_recv_epoch_key`
// holds the old epoch key and `state.recv_epoch_key` holds a copy.
// Both copies must be zeroized at their respective lifetimes:
// `prev_recv_epoch_key` is zeroized when a second KEM ratchet step fires
// (overwritten by the then-current epoch key or set to None); `recv_epoch_key`
// is zeroized by `KDF_Root` overwriting it two lines below. In Rust,
// `prev_recv_epoch_key` is `Option<Zeroizing<[u8; 32]>>` — the overwrite or
// drop triggers zeroization of the old epoch key it holds. `recv_epoch_key`
// and `send_epoch_key` are plain `[u8; 32]` (not `Zeroizing` wrappers —
// `[u8; 32]` is `Copy`, so `Zeroizing::new(val)` would copy rather than move,
// leaving the original on the stack). The overwrite of `recv_epoch_key` by
// `KDF_Root` does NOT automatically zeroize the old value; the zeroization
// responsibility belongs to the KDF_Root call that overwrites it. C/Go/Python
// implementations must explicitly zeroize the old `recv_epoch_key` before
// overwriting it at line `state.recv_epoch_key = new_epoch_key` — see §6.4
// for the analogous pattern.
state.prev_recv_ratchet_pk = state.recv_ratchet_pk
state.prev_recv_seen = state.recv_seen
// ORDERING IS CRITICAL: `prev_recv_seen = recv_seen` MUST precede
// `recv_seen = empty` (six lines below). If reversed, `empty` is copied
// into `prev_recv_seen`, silently discarding all current-epoch replay
// protection history. The error is undetectable — the new epoch proceeds
// normally, but previous-epoch duplicate detection is disabled. Compare
// §6.4's analogous `prev_send_count = send_count` MUST precede
// `send_count = 0` ordering requirement.
else:
state.prev_recv_epoch_key = None
state.prev_recv_ratchet_pk = None
state.prev_recv_seen = empty
// Derive new current epoch.
(state.root_key, state.recv_epoch_key) = KDF_Root(state.root_key, ss)
zeroize(ss) // ss is no longer needed — zeroize immediately (mirrors §6.4).
// Rust's ZeroizeOnDrop covers this automatically; C/Go/Python
// reimplementers MUST zeroize explicitly after this line.
state.recv_ratchet_pk = header.ratchet_pk
state.recv_count = 0
state.recv_seen = empty // MUST follow `prev_recv_seen = recv_seen` above.
state.ratchet_pending = true
msg_key = KDF_MsgKey(state.recv_epoch_key, header.n)
else:
// Current epoch — the `else` branch of the three-way selector:
// if prev_epoch → previous epoch (above)
// else if NOT current_epoch → new epoch / KEM ratchet (above)
// else → current epoch (here)
// "Current epoch" means header.ratchet_pk == recv_ratchet_pk.
msg_key = KDF_MsgKey(state.recv_epoch_key, header.n)
// AEAD decryption — all epoch types converge here.
plaintext = DecryptWithKey(msg_key, header, ciphertext, sender_fp, recipient_fp)
// On AEAD failure: restore_recv_state(state, snapshot), raise AeadFailed.
// --- Post-AEAD duplicate detection and recv_seen update ---
// **Security requirement**: Duplicate detection MUST be post-AEAD, not pre-AEAD.
// Pre-AEAD recv_seen lookup returns in nanoseconds for duplicates vs.
// microseconds for non-duplicates (key derivation + AEAD), creating a timing
// oracle that leaks recv_seen set membership. An attacker replaying messages
// with different counter values can probe which counters have been successfully
// decrypted. Post-AEAD ordering ensures both duplicate and non-duplicate
// messages take identical time through key derivation + AEAD.
// Duplicates succeed AEAD (same key/nonce/ciphertext) but the plaintext is
// discarded.
if prev_epoch AND NOT current_epoch:
// recv_count is NOT updated — it tracks the current epoch only.
// Previous-epoch counters occupy a different sequence space;
// unconditionally updating recv_count would break the invariant
// that recv_seen entries are < recv_count (guard 17).
if header.n in state.prev_recv_seen:
restore_recv_state(state, snapshot)
raise DuplicateMessage
if |state.prev_recv_seen| >= MAX_RECV_SEEN:
restore_recv_state(state, snapshot)
raise ChainExhausted
state.prev_recv_seen.add(header.n)
else:
// Current-epoch path. For messages that arrived as "new-epoch" (different
// ratchet_pk), the KEM ratchet step above has already updated recv_ratchet_pk
// to the new peer key — so by this point, the message's ratchet_pk matches
// the current epoch and is handled here, not in the prev_epoch branch.
// New-epoch messages follow the same post-AEAD state update as current-epoch
// messages — recv_count is incremented and n is added to recv_seen. This is
// not a separate case; the merge is intentional. Implementations that treat
// new-epoch as an independent code path and omit the recv_count update leave
// recv_count = 0 after the first new-epoch message, causing guard 17 failures
// on subsequent serialization.
if header.n in state.recv_seen:
restore_recv_state(state, snapshot)
raise DuplicateMessage
if |state.recv_seen| >= MAX_RECV_SEEN:
restore_recv_state(state, snapshot)
raise ChainExhausted
state.recv_seen.add(header.n)
state.recv_count = max(state.recv_count, header.n + 1)
// Off-by-one trap: `recv_count = header.n` (not `+ 1`) would silently fail
// guard 17 after a new-epoch ratchet. After the new-epoch step sets recv_count = 0,
// the first arriving message has n = 0 → recv_count = max(0, 0) = 0, but recv_seen
// now contains {0}. Guard 17 requires all recv_seen entries to be < recv_count —
// 0 < 0 is false → InvalidData on next serialization. The `+ 1` produces recv_count
// = 1 with recv_seen = {0}, which satisfies the invariant (0 < 1).
zeroize(msg_key)
return plaintext
function DecryptWithKey(msg_key, header, ciphertext, sender_fp, recipient_fp):
// Minimum ciphertext length: 16 bytes (Poly1305 tag, zero-length plaintext).
// AEAD libraries that don't gracefully handle sub-tag-length inputs (e.g.,
// OpenSSL EVP, some Go crypto/cipher implementations) may panic or return
// non-standard errors. Guard explicitly before calling the AEAD primitive.
if len(ciphertext) < 16:
raise AeadFailed
// No maximum ciphertext length is enforced at the Rust API level.
// XChaCha20-Poly1305 accepts inputs up to ~256 GiB, so an authenticated peer
// can supply a ciphertext of any size, causing the receiver to allocate the
// full buffer before AeadFailed fires. The CAPI imposes a 256 MiB hard limit
// (§13.2) that returns InvalidLength before this function is reached.
// Rust-layer callers and non-CAPI reimplementers MUST impose their own upper
// bound appropriate to their deployment context.
// IMPORTANT: sender_fp is the REMOTE party's fingerprint (the message sender),
// and recipient_fp is the LOCAL party's fingerprint (the message recipient).
// This is the mirror of encrypt, where sender_fp = local and recipient_fp = remote.
// A reimplementer who always uses (local_fp, remote_fp) for both directions
// produces mismatched AAD and silent AEAD failures.
nonce = 0x00{20} || big_endian_32(header.n)
header_bytes = encode_ratchet_header(header)
aad = "lo-dm-v1" || sender_fp || recipient_fp || header_bytes
return AEAD-Decrypt(msg_key, nonce, ciphertext, aad)
DuplicateMessage MUST NOT return plaintext: When a message is detected as duplicate (counter already in recv_seen or prev_recv_seen), the decrypted plaintext MUST be zeroized and the function MUST return only the DuplicateMessage error. An API that returns both the plaintext and a duplicate indicator enables application-layer double delivery despite the error signal. The duplicate message successfully decrypts (AEAD is deterministic — same key/nonce/ciphertext produces the same plaintext), but exposing the result defeats the purpose of duplicate detection.
DuplicateMessage plaintext zeroization obligation for non-RAII implementations: The duplicate check runs after AEAD decryption (§6.6 post-AEAD duplicate detection rationale). This means the plaintext output buffer has already been filled by the time DuplicateMessage is raised. Non-RAII implementations (C, Go, Python) that use a "free on error" pattern will free the buffer without zeroing it — leaking plaintext in freed-but-not-overwritten memory. The obligation is: on DuplicateMessage, explicitly zeroize the plaintext output buffer before returning the error (or before freeing the buffer). In Rust, wrapping the output buffer in Zeroizing<Vec<u8>> handles this automatically via Drop. In C: explicit_bzero(buf, len); free(buf); before returning. In Go: clear(buf) (or for i := range buf { buf[i] = 0 }) before discarding. The same obligation applies to AeadFailed — both errors cause the function to return after AEAD has already written into the output buffer.
recv_ratchet_pk is stored verbatim without pre-AEAD structural validation: When a new-epoch message arrives, header.ratchet_pk (1216 bytes) is stored as the new recv_ratchet_pk immediately before AEAD decryption runs. No structural validity check (all-zero test, low-order point check, ML-KEM key validation) is performed before storage. Invalid key material surfaces as AeadFailed via X-Wing's implicit rejection (§8.4) when the next KEM ratchet step attempts decapsulation with that key. A reimplementer who adds a pre-AEAD structural check on ratchet_pk — for example, rejecting an all-zero public key before attempting AEAD — creates a timing oracle: the check returns in nanoseconds while AEAD takes microseconds, allowing an attacker to probe key validity without triggering an AEAD attempt.
Receiver does not use pn for key derivation: The pn (previous epoch count) field in the header is authenticated via AAD but the receiver performs no other processing on it. In counter-mode, any message key is directly derivable from the epoch key and counter — there is no skip-cache scanning bounded by pn. Reimplementers from the Signal Double Ratchet ecosystem: pn has no skip-cache role here; tampering with pn causes AEAD failure (§7.3), nothing else. No validation constraint on pn is applied. Values from 0 to u32::MAX are all acceptable on the wire — the receiver MUST NOT add guards on pn relative to any state field (e.g., a "pn must be ≤ peer's send_count" check has no basis in this protocol and would reject valid messages).
Decrypt atomicity: Unlike encrypt (§6.4), decrypt achieves atomicity through snapshot/rollback rather than operation ordering. The encrypt path can guarantee atomicity by ordering — all fallible operations execute before any state mutation, so a failure leaves state unchanged by construction. The decrypt path cannot use ordering-based atomicity: on the new-epoch path, KEM decapsulation produces the shared secret ss, and only after decapsulation do the state mutations occur (prev_recv_epoch_key save, KDF_Root(root_key, ss) overwriting recv_epoch_key, resetting recv_count to 0, clearing recv_seen). Because state mutations occur after a fallible operation (decapsulation), a failure during or after mutation cannot be recovered by reordering alone. Since fallible cryptographic operations follow state mutations on this path, the only correct atomicity mechanism is snapshot/rollback — take a full snapshot before any mutation, restore it on any failure. The Rust reference implementation's save_recv_state / restore_recv_state (see helper definitions above) implement this contract. A reimplementer who attempts ordering-based atomicity for decrypt — placing mutations after all fallible operations — cannot do so correctly on the new-epoch path and will either fail to perform the KEM ratchet step or silently corrupt state on failure.
State rollback on failure: All receive-side state mutations are rolled back on any failure (decapsulation failure, chain exhaustion, AEAD failure, duplicate detection). The implementation takes a full snapshot of nine fields before any mutation: root_key, recv_epoch_key, recv_count, recv_ratchet_pk, ratchet_pending, recv_seen, prev_recv_epoch_key, prev_recv_ratchet_pk, and prev_recv_seen. On any error, the entire snapshot is restored wholesale. Send-side state is never mutated by decrypt and is not snapshotted.
recv_seen and prev_recv_seen snapshots must be deep copies: Both fields are sets of u32 values that grow during decryption. In Rust, Clone on HashSet<u32> always produces an independent deep copy. In Python, Go, Java, and other languages with reference semantics, a simple variable assignment copies the reference — not the contents. Mutating the live set (e.g., inserting the new counter into recv_seen) would silently corrupt the snapshot, making rollback a no-op (the snapshot points to the same backing storage as the live set). The snapshot MUST be a fully independent set with the same elements — a deep copy whose mutations during decrypt_inner do not affect the snapshot, and whose restoration on error completely replaces the live set's contents.
recv_ratchet_pk and prev_recv_ratchet_pk snapshots also require deep copies: These public-key fields have the same reference-semantics trap as the recv_seen sets. The new-epoch path executes prev_recv_ratchet_pk = recv_ratchet_pk (assignment / shallow copy) and then recv_ratchet_pk = header.ratchet_pk (mutation). In Rust, public-key structs are Clone-derived and Copy is not implemented (they're non-trivial), so the snapshot Clone is always a value copy — no alias. In Python/Go/Java, if the snapshot holds a reference to the same object as the live field, the second assignment (recv_ratchet_pk = header.ratchet_pk) does not corrupt the snapshot — the snapshot still holds the original reference, which now also happens to be the live prev_recv_ratchet_pk. But on rollback, restoring the snapshot recv_ratchet_pk to the snapshot value points it back to the original object (now shared with the live prev_recv_ratchet_pk). This leaves recv_ratchet_pk and prev_recv_ratchet_pk pointing to the same object post-rollback, so the next same-epoch message (which should route via the current recv_ratchet_pk) will compare equal to prev_recv_ratchet_pk and route incorrectly via the new-epoch path — failing AEAD, appearing as a silent session corruption. The fix: deep-copy all public key fields in the snapshot. In Python: snapshot_recv_ratchet_pk = bytes(live_recv_ratchet_pk) (or equivalent). In Go: copy the underlying byte array rather than taking a slice reference.
Snapshot zeroization obligation on the success path: The snapshot copies of root_key, recv_epoch_key, and prev_recv_epoch_key are secret key material. On the success path, the snapshot is discarded rather than restored — but discarding must mean zeroizing, not merely freeing or letting the memory go out of scope. In Rust, Zeroizing<[u8; 32]> zeroizes automatically on drop, so the success path is correct by construction. In C, Go, Python, or other non-RAII languages, the caller who implements this function MUST explicitly zero these three fields in the snapshot before returning from the success path. Failing to do so leaves copies of old key material in freed-but-not-zeroed memory, where they are recoverable via heap-scanning for the duration they remain un-overwritten. The rollback invariant is "on error restore, on success zeroize" — not "on error restore, on success ignore."
Why ratchet_pending is in the snapshot: A new-epoch decrypt tentatively sets ratchet_pending = true (§6.6 KEM ratchet step) before AEAD runs. If AEAD fails and ratchet_pending is not restored to its pre-decrypt value, the next encrypt() call fires a KEM ratchet step against the (also rolled-back) old recv_ratchet_pk using the rolled-back root_key, producing a ciphertext the peer cannot process — silent session corruption with no error on the sender side. A reimplementer who implements partial rollback (e.g., omits ratchet_pending thinking it is a flag that should remain set after any new-epoch attempt) gets exactly this failure mode.
Previous epoch grace period: When a KEM ratchet step occurs, the current epoch key is preserved as prev_recv_epoch_key. Late-arriving messages from that epoch can still be decrypted using counter-mode derivation from the old epoch key. The previous epoch key is zeroized when a second KEM ratchet step rotates it out. This provides a one-epoch grace period for out-of-order delivery without storing per-message keys. "One epoch" means one receive epoch — one KEM ratchet step in the receive direction. A send-side KEM ratchet step does not rotate prev_recv_epoch_key. Implementations that interpret "epoch" as any KEM ratchet step (send or receive) will incorrectly expect recovery across two direction changes.
recv_count asymmetry across epochs: recv_count tracks only the current receive epoch — it is the high-water mark for current-epoch message counters. Previous-epoch messages update prev_recv_seen but do NOT update recv_count. This is critical for guard 17 (§6.8): recv_seen entries must be < recv_count. If previous-epoch messages updated recv_count, a previous-epoch counter (which could be any value in [0, u32::MAX − 1]) would corrupt the high-water mark for the current epoch, invalidating the guard 17 invariant. There is no analogous prev_recv_count — when a receive epoch rotates into previous, its recv_count is not preserved. prev_recv_seen entries are bounded only by guard 14 and guard 15.
Timing asymmetry across epoch paths: New-epoch decrypt performs X-Wing decapsulation (~1ms); current-epoch and previous-epoch paths are O(1) HMAC key derivation (~microseconds). This timing difference is not a side-channel oracle because the epoch type is determined solely by comparing header.ratchet_pk (a cleartext header field) to stored public keys — an observer who can measure timing already knows the epoch type from the public key. The constant-time requirement (Appendix E) applies to the public-key comparisons themselves, not to equalizing path runtimes. Reimplementers MUST NOT add dummy KEM operations to equalize paths — this would waste CPU for no security benefit.
Decrypt error table: decrypt() / soliton_ratchet_decrypt returns five distinct variants:
-
InvalidData: four distinct conditions, all returningInvalidData:- Dead session (all-zero
root_key, pre-snapshot): the session has been permanently terminated by a session-fatal encrypt error (§6.5), which zeroizedroot_key. ThisInvalidDatais not retryable for any message — the session is irrecoverable. Re-establish via LO-KEX. - Epoch too old (missing
prev_recv_epoch_keyfor a previous-epoch message, pre-snapshot): the session is still live, but the sender's message is from an epoch older than the one-epoch grace period (prev_recv_epoch_keyhas already been rotated out). ThisInvalidDatais non-retryable for that specific message, but the session remains functional for current-epoch and new-epoch messages. kem_ctabsent in a new-epoch message (post-snapshot, rollback is a no-op): the header indicates a new epoch (newrecv_ratchet_pk) but carries no KEM ciphertext. No state mutations have been applied when this fires. Non-retryable for that message (structurally malformed).send_ratchet_skisNoneon the new-epoch path (post-snapshot, rollback is a no-op): decapsulation of the peer's new-epoch ciphertext requires the local X-Wing secret key, but the party has never sent (no key was generated yet). Fires before any state mutation. Non-retryable for that message.
Callers who need to distinguish the dead-session condition from the others may inspect
root_keyfor the all-zero sentinel before calling (checking liveness) — there is no error-code distinction at the API level between the four conditions. State is unchanged for all four. The post-snapshot cases (third and fourth) require no rollback because no mutation precedes them, but the unconditional snapshot-and-restore mechanism handles them correctly regardless. - Dead session (all-zero
-
ChainExhausted: recv_seen or prev_recv_seen saturation (transient — resets on next KEM ratchet step), or header.n == u32::MAX (counter exhaustion, not a structural error). State is unchanged. NOT session-fatal — see §12 modes (2). -
DuplicateMessage: counter already in recv_seen or prev_recv_seen. State is restored via snapshot rollback on all paths — the snapshot is always taken before epoch-specific processing begins. On the current-epoch and previous-epoch paths, no state has been mutated before duplicate detection fires (key derivation and AEAD precede it, but these are read-only operations with respect to ratchet state); the snapshot restore is therefore a no-op in practice. On the new-epoch path, KEM ratchet step mutations (epoch rotation, recv_count reset, recv_seen clear) have occurred before duplicate detection — butDuplicateMessageis structurally unreachable on the new-epoch path because recv_seen was just cleared; see §6.7. In all reachable cases, the snapshot restore is correct and necessary. Plaintext is zeroized and not returned. Rollback is MANDATORY for DuplicateMessage regardless of whether visible state mutations preceded it — a reimplementer who omits rollback "because the state wasn't mutated yet" silently corrupts the session on edge-case paths. -
DecapsulationFailed: X-Wing decapsulation failure on the new-epoch path — fires at XWing.Decaps() (step 1 of the new-epoch branch), before AEAD, before state mutations — decapsulation is the first operation on the new-epoch branch; epoch rotation (saving previous epoch keys, overwritingrecv_epoch_key, resettingrecv_count, clearingrecv_seen) has not yet occurred when this error fires (see §6.6 pseudocode: decapsulation at the top of the new-epoch branch, epoch rotation below). In practice unreachable with valid-length ciphertexts — implicit rejection (§8.4) makes all correctly-sized ciphertexts succeed decapsulation and fail at AEAD instead. Snapshot rollback is applied unconditionally by the snapshot-and-restore mechanism, even though no mutation has occurred — the snapshot is taken before all epoch-specific processing and restored on any error return. State is rolled back via snapshot. NOT session-fatal for decrypt — the session remains usable. -
AeadFailed: authentication tag mismatch. State is rolled back via snapshot. NOT session-fatal for decrypt (contrast:AeadFailedon encrypt IS session-fatal, §6.5). The session can process subsequent messages.
The pre-snapshot vs. post-snapshot distinction matters for rollback: ChainExhausted and the two pre-snapshot InvalidData conditions (dead session, epoch too old) fire before any cryptographic state mutation — rollback is a no-op even when the snapshot exists. The two new-epoch-path InvalidData conditions (kem_ct absent, send_ratchet_sk None) fire after the snapshot but also before any state mutation — rollback is a no-op for these as well, but the distinction from the pre-snapshot InvalidData conditions matters: the new-epoch path does apply mutations later (see §6.6 KEM ratchet steps), so a reimplementer who checks "can this path mutate state?" at InvalidData fire time gets a different answer depending on which condition fired. DuplicateMessage and AeadFailed occur after state mutations have been tentatively applied and require snapshot restoration. DecapsulationFailed occurs before state mutations — decapsulation is the first step on the new-epoch path, preceding epoch rotation (see §6.6 pseudocode) — but the snapshot-and-restore mechanism applies unconditionally regardless. Note: the reference implementation takes the snapshot unconditionally before all guards (line snapshot = save_recv_state(state), after identify_epoch() and before epoch-specific processing). The phrase "pre-mutation" describes the semantic behavior (no state was actually changed), not a conditional snapshot implementation. A reimplementer who omits the snapshot for InvalidData/ChainExhausted paths on the grounds that "no state was mutated yet" is correct only if those errors genuinely fire before any mutation — but implementing conditional snapshotting adds fragility: if a future refactor moves a mutation earlier, the conditional snapshot silently stops covering it. The unconditional snapshot is simpler and correct by construction. A reimplementer who omits rollback for DuplicateMessage or DecapsulationFailed — treating them as "pre-mutation" because they seem like early checks — silently corrupts the session state on those error paths.
Out-of-order messages: Within the current epoch, messages may arrive in any order. Each message key is derived directly from the epoch key and the message counter — no sequential chain advancement is needed. The recv_seen set prevents duplicate processing. Between epochs, messages from the immediately previous epoch are also handled (see above).
Plaintext zeroization obligation: The decrypted plaintext is secret material. In Rust, decrypt() returns Zeroizing<Vec<u8>>, which automatically zeroizes the buffer when dropped. Languages without RAII-style automatic cleanup (C, Go, Python) must explicitly zeroize the plaintext buffer after use — the library cannot manage the caller's copy. This obligation parallels the ratchet state zeroization documented in §6.10 but is easier to overlook because plaintext feels "transient." A plaintext buffer that survives in freed-but-not-zeroed memory is vulnerable to the same heap-scanning attacks that key material is.
6.7 Duplicate Detection
Duplicate detection uses a recv_seen set (current epoch) and prev_recv_seen set (previous epoch) that track successfully-decrypted message counters. Both sets are bounded at MAX_RECV_SEEN = 65536 entries as defense-in-depth against memory exhaustion.
A message is a duplicate if its counter n is already in the appropriate recv_seen set. Messages from epochs older than the previous epoch are rejected by the KEM ratchet step (the old epoch key no longer exists; AEAD decryption will fail).
Unlike the Signal Double Ratchet's skip cache (which stores 32-byte message keys per skipped position), the recv_seen sets store only 4-byte counters — no secret key material. This eliminates the need for TTL expiry, purge throttling, and the ZeroizeOnDrop concerns associated with HashMap rehashing.
New-epoch path: For messages that trigger a KEM ratchet step (new-epoch), DuplicateMessage is unreachable by construction — the KEM ratchet step clears recv_seen to empty before duplicate detection runs, so the contains() check always returns false. The rollback covers this path only for theoretical completeness.
6.7.1 Worked Example: Four-Message Exchange
The following walkthrough traces a minimal Alice↔Bob exchange, showing the header values (n, pn, kem_ct) and the recv_count high-water mark for each message. This is the primary checkable reference for reimplementers verifying their counter and ratchet logic. recv_count is updated as max(recv_count, n+1) on each received message and resets to 0 on KEM ratchet (epoch transition).
Initial state (after §5.4/§5.5 session establishment):
Alice: send_count=1, recv_count=0, prev_send_count=0
ratchet_pending=false, send_ratchet_pk=Some(EK_pub)
Bob: send_count=0, recv_count=1, prev_send_count=0
ratchet_pending=true, send_ratchet_pk=None
Message 1 (A→B): Alice continues her first epoch (no ratchet needed).
n=1, pn=0, kem_ct=None
Alice's send_count advances to 2. Bob decrypts with his recv_epoch_key at counter 1. Bob's recv_count updates: max(1, n+1) = max(1, 2) = 2.
Message 2 (B→A): Bob's first send. ratchet_pending=true fires the KEM ratchet step.
n=0, pn=0, kem_ct=Some(...)
Bob had no previous send epoch (prev_send_count=0), so pn=0. The KEM ciphertext is encapsulated against Alice's send_ratchet_pk (which is EK_pub). Alice decrypts, sees the new ratchet_pk, and sets ratchet_pending=true. Alice's recv_count resets to 0 on epoch transition (KEM ratchet step), then updates: max(0, n+1) = max(0, 1) = 1.
Message 3 (A→B): Alice sends again. ratchet_pending=true (from receiving Bob's KEM ciphertext) fires her KEM ratchet step.
n=0, pn=2, kem_ct=Some(...)
pn=2 is the critical non-obvious value. Alice's previous send epoch had send_count=2 at the moment the ratchet fired (one message sent at n=1, which advanced send_count to 2). A reimplementer who initializes Alice's send_count at 0 instead of 1 would see pn=1 here. Bob's recv_count resets to 0 on epoch transition, then updates: max(0, n+1) = max(0, 1) = 1.
Message 4 (B→A): Bob sends again. ratchet_pending=true (from receiving Alice's KEM ciphertext) fires his KEM ratchet step.
n=0, pn=1, kem_ct=Some(...)
Bob's previous send epoch had send_count=1 (pn=1). Alice's recv_count resets to 0 on epoch transition, then updates: max(0, n+1) = max(0, 1) = 1.
After these four messages, both parties have completed two full KEM ratchet cycles. Every subsequent direction change triggers a new KEM ratchet step with the expected pn = send_count at the ratchet boundary.
Hard limit on late-arriving messages: The one-epoch grace period (§6.6) means messages from the immediately previous receive epoch can still be decrypted. Messages from any older epoch are permanently undecryptable — the epoch key was zeroized when the second KEM ratchet step rotated it out. This is a protocol-level hard limit, not a buffering opportunity: no amount of caching or reordering at the transport layer can recover a message whose epoch key has been zeroized. Application designers must ensure that transport-layer message ordering keeps latency within one direction change. In practice, epochs in an active conversation are short (1-10 messages between direction changes), so only messages delayed across two or more direction changes are lost.
6.8 Ratchet State Serialization
Ratchet state is serialized for encrypted persistent storage. The caller MUST authenticated-encrypt the output before persisting (e.g., via §11 storage encryption) — the serialized form contains all secret key material.
Epoch increment on serialization: to_bytes increments the epoch counter before writing it to the blob and returns the new epoch as its second return value: (blob, new_epoch). The stored value is epoch + 1, not the pre-serialization epoch. from_bytes loads this value as-is — no increment on load. The idiomatic anti-rollback pattern is to persist both the blob and new_epoch - 1 as the min_epoch for subsequent loads — not new_epoch itself. Using new_epoch directly as min_epoch makes the current blob permanently unloadable: the guard new_epoch > new_epoch is false, so from_bytes_with_min_epoch(blob_N, new_epoch) always returns InvalidData, breaking crash recovery. The correct stored value is new_epoch - 1, ensuring new_epoch > new_epoch - 1. See Caller Obligation 2 for the full crash-safe commit order. A reimplementer who computes epoch + 1 manually instead of using the return value risks off-by-one errors. A reimplementer who increments on load instead of on save produces incompatible blobs and breaks anti-rollback (the stored epoch would be one behind, potentially equal to the last-seen epoch instead of strictly greater).
Ownership-consuming serialization: to_bytes consumes (invalidates) the in-memory ratchet state. After serialization, the original state is zeroized and no longer usable. This prevents ratchet forking: if two copies of the same state existed simultaneously, both could encrypt with the same (epoch_key, send_count) pair, causing catastrophic AEAD nonce reuse. In languages without move semantics (C, Go, Python), implementations MUST explicitly zeroize and disable the state after serialization — failing to do so enables nonce reuse and full plaintext recovery. Exception — ChainExhausted: When to_bytes returns ChainExhausted (guard 24: epoch at u64::MAX, or guard triggered by counter saturation), the state is NOT consumed — the in-memory ratchet remains valid and can continue sending/receiving. See guard 24 for recovery semantics. A reimplementer who models to_bytes as always-consuming will destroy a recoverable session on counter exhaustion. Mechanism — can_serialize() predicate: In languages with consuming/move semantics (including Rust), can_serialize() MUST be called before to_bytes — it is not optional. Calling to_bytes(self) directly without a prior can_serialize() check risks consuming (moving) the session into to_bytes and losing it if to_bytes returns ChainExhausted. In Rust, once the session is moved into to_bytes and the function returns an error, the session is gone (the self was consumed). The "state not consumed on ChainExhausted" contract described above applies only to the CAPI layer (which uses the can_serialize() pre-check before taking ownership); at the Rust API level, the caller is responsible for calling can_serialize() first. The Rust core exposes RatchetState::can_serialize(&self) -> bool, which checks all six conditions that to_bytes would fail on: send_count == u32::MAX, recv_count == u32::MAX, prev_send_count == u32::MAX, epoch == u64::MAX, recv_seen.len() >= MAX_RECV_SEEN, or prev_recv_seen.len() >= MAX_RECV_SEEN. If can_serialize() returns true, to_bytes is guaranteed to succeed (no ChainExhausted). A reimplementer's can_serialize() must cover exactly these six conditions. The CAPI layer calls can_serialize() before taking ownership, which is why the CAPI to_bytes only visibly checks epoch — the other conditions are filtered by the pre-check. recv_count reachability: Unlike send_count, which is guarded at the encrypt side (§6.5 ChainExhausted fires before send_count reaches u32::MAX), recv_count has no equivalent decrypt-side guard — rejecting a valid message solely because it would push recv_count to u32::MAX would be incorrect. A message with header.n = u32::MAX - 1 is accepted, producing recv_count = u32::MAX. This is reachable only after ~4.3 billion received messages in a single epoch without a KEM ratchet step — implausible but structurally possible. Once recv_count == u32::MAX, can_serialize() returns false and the session is un-serializable until the next KEM ratchet step, which resets recv_count to 0 (§6.6). Recovery is through a direction change (the peer sends, triggering a KEM ratchet). If the session is one-directional with no peer replies, a new LO-KEX exchange is required.
Defense-in-depth conditions: can_serialize() also checks recv_seen.len() >= MAX_RECV_SEEN and prev_recv_seen.len() >= MAX_RECV_SEEN. The runtime cap in decrypt (§6.6) prevents these from firing in practice, but without the pre-check, a future refactor removing the runtime cap would cause to_bytes to fail with InvalidData rather than ChainExhausted, breaking the documented guarantee that can_serialize() == true implies to_bytes succeeds. Error type note: if the recv_seen size cap is somehow bypassed (future refactor, direct state manipulation), to_bytes returns InvalidData — not ChainExhausted — because the overflow is a structural violation, not a counter exhaustion. The can_serialize() predicate exists precisely to unify these different underlying error types into a single boolean: a reimplementer who checks only for ChainExhausted from to_bytes will miss the InvalidData from recv_seen overflow and treat it as a non-recoverable failure when it is actually recoverable via KEM ratchet step (same as counter exhaustion).
InvalidData from to_bytes consumes the session state — asymmetry with ChainExhausted: When to_bytes returns ChainExhausted, the CAPI layer's can_serialize() pre-check ensured the state was never consumed (handle not nulled, session still live). When to_bytes returns InvalidData (the recv_seen overflow path if can_serialize() is bypassed), the state IS consumed: at the Rust API level, self was moved into to_bytes and the session is gone; at the CAPI level, the handle is nulled on any path that takes ownership and then fails. A CAPI caller treating InvalidData from to_bytes as retryable — analogous to ChainExhausted — is holding a dangling handle. The asymmetry: ChainExhausted from to_bytes → state not consumed, retryable (wait for direction change); InvalidData from to_bytes → state consumed, session lost, must re-establish via LO-KEX.
Scope of can_serialize(): The predicate guarantees only that ChainExhausted will not be returned. It does not check liveness conditions like root_key != 0x00{32} (guard 25). to_bytes() succeeds on a dead/reset session — all counters are within bounds, so can_serialize() returns true and to_bytes() produces a blob. The failure is deferred: from_bytes() subsequently rejects the all-zero root_key via guard 25 (InvalidData). can_serialize() == true therefore guarantees to_bytes() success, but does NOT guarantee from_bytes() success on the resulting blob. In practice this is academic: encrypt() and decrypt() both reject dead sessions before any state mutation, so a dead session never accumulates state worth serializing. The full guarantee for a round-trip that survives both to_bytes() and from_bytes() is can_serialize() == true AND the session is alive (initialized via init_alice/init_bob and not subsequently reset()).
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.
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.
prev_recv_seen recovery requires two KEM ratchet steps, not one. When recv_seen saturates, one KEM ratchet step clears it (new epoch starts with empty recv_seen). However, when prev_recv_seen saturates (the previous-epoch path hits the cap), the first KEM ratchet step copies the current recv_seen (which may itself be full) into prev_recv_seen — overwriting the saturated set with another potentially-full set. Only the second KEM ratchet step clears prev_recv_seen by rotating it out entirely (§6.6: the previous-epoch state is overwritten by the current epoch rotating into previous, and the second ratchet step makes the previously-rotated-in set into prev_recv_seen, which was the then-current recv_seen — empty or small if the second epoch was short). A caller who expects one direction change to unblock all ChainExhausted errors from decrypt() will be wrong for the previous-epoch saturation case — two direction changes (two KEM ratchet steps) are required. Qualification: two steps are sufficient only if the second epoch accumulates fewer than MAX_RECV_SEEN messages before the second direction change. If the second epoch also saturates recv_seen, the first KEM step copies that full set into prev_recv_seen, and a third direction change is needed. In the degenerate case where every epoch saturates, each direction change clears recv_seen but may refill prev_recv_seen — the pattern converges only when an epoch is short enough to stay below the cap.
Wire format (version 0x01):
[version: 1 byte = 0x01]
[epoch: u64 BE — anti-rollback monotonic counter]
[root_key: 32 bytes]
[send_epoch_key: 32 bytes]
[recv_epoch_key: 32 bytes]
[local_fp: 32 bytes]
[remote_fp: 32 bytes]
[send_ratchet_sk: optional field]
[send_ratchet_pk: optional field]
[recv_ratchet_pk: optional field]
[prev_recv_epoch_key: optional 32-byte field — EXCEPTION: encoded as 0x01 + 32 bytes (no 2-byte length prefix)]
[prev_recv_ratchet_pk: optional field]
[send_count: u32 BE]
[recv_count: u32 BE]
[prev_send_count: u32 BE]
[ratchet_pending: 1 byte, 0x00=false, 0x01=true]
[num_recv_seen: u32 BE]
[recv_seen entries × num_recv_seen: each u32 BE, sorted ascending]
[num_prev_recv_seen: u32 BE]
[prev_recv_seen entries × num_prev_recv_seen: each u32 BE, sorted ascending]
Optional field encoding: Each optional field is prefixed with a marker byte:
0x00— absent (1 byte total; no data follows)0x01— present (1-byte marker + 2-byte BE length + data bytes)
Decoders MUST treat any marker byte other than 0x00 or 0x01 as InvalidData. Do NOT treat arbitrary non-zero values as "present" — doing so creates format malleability (multiple byte values encoding the same logical state) and accepts blobs that no conforming encoder produces. This strictness applies equally to the ratchet_pending boolean (which uses the same 0x00/0x01 encoding).
Exception: prev_recv_epoch_key uses fixed-size encoding (0x01 + 32 bytes, no 2-byte length prefix) since the size is always exactly 32 bytes. Implementers MUST NOT apply the general 0x01 + length + data rule to this field — doing so produces blobs that are not interoperable.
Worked byte sequence for the present case: If prev_recv_epoch_key = [0xAA × 32], the encoded field is: 01 aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa aa (33 bytes — 1-byte marker followed directly by 32 key bytes). Compare with a general optional field: if send_ratchet_sk were present, its encoding would be 01 09 80 bb bb...bb (1-byte marker + 2-byte BE length 0x0980 = 2432 + 2432 data bytes). For prev_recv_epoch_key, the two length-prefix bytes are absent — the marker 0x01 is followed immediately by the 32 key bytes. A decoder that reads a 2-byte length prefix after the 0x01 marker would interpret key bytes 1-2 as a spurious length, then misalign all subsequent fields by 2 bytes, producing InvalidData with no diagnostic pointing to this specific field.
A present marker with a zero-length body is rejected as InvalidData.
Expected field sizes for length-prefixed optional fields: Decoders MUST reject blobs where the 2-byte length prefix does not equal the expected size:
| Field | Expected size | Type |
|---|---|---|
send_ratchet_sk |
2432 bytes | Fully expanded X-Wing secret key (§8.5). The 2432-byte size is the expanded form (32 X25519 bytes + 2400 ML-KEM-768 expanded bytes) — NOT the 32-byte seed. Guard 2 rejects any blob where the length prefix is not exactly 2432. Storing the compact 32-byte seed and re-expanding at load time produces a different length and triggers guard 2 with InvalidData. |
send_ratchet_pk |
1216 bytes | X-Wing public key |
recv_ratchet_pk |
1216 bytes | X-Wing public key |
prev_recv_epoch_key |
32 bytes | Epoch key (fixed-size encoding, no length prefix — see exception above) |
prev_recv_ratchet_pk |
1216 bytes | X-Wing public key |
Version history:
0x01-0x04: previous chain-ratchet designs (not supported).0x05: counter-mode epoch keys, previous epoch key, recv_seen sets (current).
Forward compatibility: Implementations MUST reject any version byte other than 0x01 with UnsupportedVersion. Do NOT attempt to parse unknown versions using current-version rules — field layout changes between versions are not backwards-compatible.
Annotated byte-offset table: Appendix F contains a byte-offset-annotated layout for Alice's and Bob's initial states (field name, byte range, and size), useful for debugging serialization interoperability. Refer to Appendix F when implementing an encoder — the compact field listing above gives field order but not absolute offsets, which depend on the sizes of preceding optional fields and are easiest to verify against the Appendix F examples.
Deserialization validation (twenty-four active guards, numbered 1-25 with guard 4 removed in v5; implementations may decompose these into more code-level checks — e.g., guard 20 produces two checks (one per fingerprint), guard 19+20 together produce three checks). All 24 active guards apply exclusively to from_bytes / from_bytes_with_min_epoch. The to_bytes path enforces only the six can_serialize() conditions (guards 5 and 24 for counters/epoch, plus the two recv_seen size caps). Guards like 25 (all-zero root_key) are not checked on serialization — serializing a dead session produces a valid blob that fails on reload, which is acceptable for cleanup flows:
-
Version byte must be
0x01; other values →UnsupportedVersion. -
send_ratchet_skandsend_ratchet_pkmust be both present or both absent. -
recv_count > 0requiresrecv_ratchet_pkpresent. -
(Removed in v5.)
recv_count == 0withrecv_ratchet_pkpresent is a valid state. It occurs after a KEM ratchet step in decrypt (§6.6) setsrecv_ratchet_pkto the new peer key and resetsrecv_countto 0, before any message in the new epoch is successfully decrypted. If the triggering message's AEAD fails and the state is rolled back, or if serialization occurs before the next successful decrypt, the blob hasrecv_count == 0withrecv_ratchet_pkpresent. Rejecting this combination makes rollback-then-serialize a permanent deserialization failure — sessions become un-deserializable whenever serialization occurs between a KEM ratchet step and the first successful AEAD in the new epoch, a common app-lifecycle event (e.g., the app is backgrounded or killed between receiving a new-epoch header and completing decryption). Reimplementers adding sanity checks MUST NOT treat this combination as invalid. -
No counter may equal
u32::MAX→InvalidData. Forsend_count, this is unreachable by construction — the encrypt-sideChainExhaustedguard (§6.5) fires atu32::MAX - 1, preventingsend_countfrom reachingu32::MAX. Invariant:send_count ∈ [0, u32::MAX − 1]in any reachable ratchet state (specifically[1, u32::MAX − 1]in Alice's initial epoch,[0, u32::MAX − 1]in post-ratchet epochs). Contrast:recv_count ∈ [0, u32::MAX]— there is no decrypt-side guard preventingu32::MAX. Forprev_send_count, the same interlock applies:prev_send_countis set fromsend_countduring a KEM ratchet step, and the encrypt guard preventssend_countfrom reachingu32::MAX. Invariant:prev_send_count < u32::MAXin any reachable ratchet state. A reimplementer who relaxes the encrypt-side guard (e.g., tosend_count >= u32::MAX - 1) would allowprev_send_countto reachu32::MAX, causing this deserialization guard to fire and permanently breaking the session. Forrecv_count, unlike the send-side counters,u32::MAXis legitimately reachable — a peer who sends messagen = u32::MAX − 1causesrecv_count = max(recv_count, n + 1) = u32::MAX(there is no decrypt-side guard analogous to the encrypt-sideChainExhausted). This guard makes the session un-serializable, but the session remains functional in memory. Recovery: the peer triggers a KEM ratchet step (direction change), which resetsrecv_countto 0 in the new epoch. The error returned SHOULD indicate "un-serializable, recoverable by direction change" rather than "corruption" — thecan_serialize()predicate (which checks all three counters) will returnfalseuntil the KEM ratchet step occurs.Asymmetry between
to_bytesandfrom_bytesforrecv_count = u32::MAX: Whenrecv_count == u32::MAX,can_serialize()returnsfalseandto_bytesreturnsChainExhausted— the state is not consumed, and the session remains functional in memory. When a blob withrecv_count == u32::MAXis loaded from storage (an unusual scenario — such a blob cannot originate from a correctly-functioning implementation, sincecan_serialize()preventsto_bytesfrom ever writing such a blob; it could originate from a different implementation without thecan_serialize()pre-check, storage corruption, or a compatibility scenario with a prior implementation version),from_bytesreturnsInvalidData(this guard). The error semantics differ:ChainExhaustedfromto_bytesis recoverable (peer triggers direction change);InvalidDatafromfrom_bytesis not (the session is permanently broken from the persistence layer's perspective). A caller handling the persistence layer MUST distinguish these two cases — the recovery action differs: forChainExhaustedfromto_bytes, wait for the peer to send; forInvalidDatafromfrom_bytes, discard the session and re-establish via LO-KEX. -
ratchet_pendingrequiresrecv_ratchet_pkpresent. -
send_count > 0requiressend_ratchet_skpresent. -
send_count == 0withsend_ratchet_skpresent →InvalidData. This state exists transiently insideencrypt()betweenPerformKEMRatchetSendsettingsend_count = 0and the post-AEADsend_count += 1. The exclusive access model (§6.2) prevents serialization from capturing that window — a correctly-implemented ratchet never produces a blob with this combination. -
send_count == 0 && !ratchet_pending && recv_ratchet_pk.is_some() && send_ratchet_sk.is_none()→InvalidData. This state means a peer ratchet key was received butratchet_pendingis false — unreachable becauserecv_ratchet_pkis only set during session init (whereratchet_pending = truefor Bob) or during a KEM ratchet step in decrypt (which always setsratchet_pending = true). -
All-default state →
InvalidData. Precise predicate (5 conditions):send_count == 0 && recv_count == 0 && !ratchet_pending && send_ratchet_sk.is_none() && recv_ratchet_pk.is_none(). The remaining fields (prev_send_count,send_ratchet_pk,prev_recv_epoch_key,prev_recv_ratchet_pk,recv_seen,prev_recv_seen) are not checked — they are redundant given other guards:send_ratchet_pkis constrained by guard 2 (co-presence withsend_ratchet_sk);prev_recv_epoch_keyandprev_recv_ratchet_pkare constrained by guard 13 (co-presence);prev_send_count == 0is implied bysend_count == 0(prev_send_count is set from send_count at ratchet time, and no ratchet has fired if send_count == 0 and send_ratchet_sk is absent);recv_seenemptiness is implied byrecv_count == 0(guard 17 requires entries < recv_count);prev_recv_seenemptiness is implied byprev_recv_epoch_key.is_none()(guard 18). This guard catches blobs where root_key and fingerprints are non-zero but everything else is at initialization defaults — a state unreachable by construction (init_alice sets send_count = 1, init_bob sets recv_count = 1 and ratchet_pending = true). -
Trailing bytes after complete parse →
InvalidData.
Truncated input returns InvalidData, not InvalidLength: If the blob is shorter than expected (buffer runs out before all required fields are read), the decoder returns InvalidData — not InvalidLength. InvalidLength would leak parser state: an attacker could probe inputs of increasing length and observe the error transition from InvalidLength to InvalidData, revealing the byte offset where parsing advanced past the size check, progressively exposing the internal blob layout. Using InvalidData for truncation collapses this oracle. A reimplementer who returns InvalidLength for short blobs produces a distinguishable error type for the same condition.
12. epoch must be strictly greater than the last-seen epoch for the same session → InvalidData (anti-rollback; prevents storage-layer replay of older blobs that would cause AEAD nonce reuse).
13. prev_recv_epoch_key and prev_recv_ratchet_pk must be both present or both absent.
14. num_recv_seen and num_prev_recv_seen must be strictly less than MAX_RECV_SEEN (65536) — consistent with the runtime cap in decrypt that rejects at the boundary.
15. Each recv_seen entry and each prev_recv_seen entry must not equal u32::MAX. Both sets must be in strictly ascending order (which also enforces no duplicates). Non-ascending order or a u32::MAX entry in either set → InvalidData. Rationale for u32::MAX exclusion: the decrypt-side ChainExhausted guard (§6.6) fires before processing any message with header.n == u32::MAX, preventing such a message from ever being successfully decrypted. A counter of u32::MAX can therefore never legitimately appear in recv_seen or prev_recv_seen — any blob that claims otherwise is malformed. Rationale for sorted order: deterministic serialization — identical ratchet state must produce byte-for-byte identical blobs for persistent storage recovery and anti-rollback epoch comparison. Both recv_seen and prev_recv_seen must be sorted; sorting only one set breaks blob determinism. The sort is enforced at serialization time (to_bytes); a reimplementer who stores entries in insertion order and sorts lazily on decode violates the identical-blob invariant.
16. prev_recv_epoch_key all-zero → InvalidData (deterministic message keys).
17. Each recv_seen entry must be < recv_count (high-water mark consistency). There is no analogous prev_recv_count high-water mark for prev_recv_seen — when a receive epoch rotates into previous, its recv_count is not preserved. prev_recv_seen entries are bounded only by guard 14 (< MAX_RECV_SEEN) and guard 15's u32::MAX exclusion; any value in [0, u32::MAX − 1] is valid. This asymmetry is intentional and safe: prev_recv_seen is a bounded, append-only set (bounded by guard 14) that is discarded on the next KEM ratchet step — no computation depends on a high-water mark relationship between prev_recv_seen entries and any stored counter. A reviewer noticing the asymmetry should not add a prev_recv_count field to close it — doing so would require persisting the previous epoch's recv_count, adding wire format complexity for no security benefit. The absence of the check is a known, deliberate asymmetry.
18. Non-empty prev_recv_seen requires prev_recv_epoch_key present. The converse does not hold — prev_recv_epoch_key present with prev_recv_seen empty is valid (occurs immediately after the first KEM ratchet step, before any previous-epoch messages arrive).
19. local_fp == remote_fp → InvalidData (self-sessions break AAD symmetry).
20. All-zero local_fp or remote_fp → InvalidData (indicates uninitialized fingerprints).
21. All-zero send_ratchet_sk[0..32] (the X25519 scalar portion, per §8.1 layout) when present → InvalidData. Only the X25519 scalar (first 32 bytes) is checked: an all-zero scalar produces an all-zero X25519 DH output during X-Wing decapsulation, collapsing the combiner and eliminating X25519's contribution to the shared secret. The ML-KEM-768 component (bytes 32-2432) has internal structure validated by the ml-kem crate's own deserialization — an all-zero ML-KEM key is rejected at the library boundary before this guard runs. Note: X25519 clamping (RFC 7748 §5: set bits 0,1,2 clear, bit 254 set, bit 255 clear) makes an all-zero scalar impossible for honestly-generated keys — this guard catches only maliciously crafted or corrupted blobs.
22. recv_count > 0 OR recv_ratchet_pk present, with all-zero recv_epoch_key → InvalidData (liveness: a post-initial-state session must have a real epoch key — otherwise HMAC-based message key derivation produces publicly computable keys).
23. send_count > 0 && !ratchet_pending with all-zero send_epoch_key → InvalidData (liveness: messages were sent with a non-functional epoch key). The !ratchet_pending conjunction is defense-in-depth: the state (ratchet_pending=true, send_count > 0, all-zero send_epoch_key) is unreachable by construction — any send_count > 0 implies a prior KEM ratchet step that produced a non-zero send_epoch_key. The condition prevents incorrectly rejecting a theoretically-possible-but-implementation-unreachable serialized state.
**Bob's initial all-zero `send_epoch_key` is valid**: Bob's initial state has `send_count = 0`, `ratchet_pending = true`, and `send_epoch_key = 0x00{32}` (placeholder). Guards 22 and 23 both skip this state by their conjunctions: guard 22 requires `recv_count > 0` OR `recv_ratchet_pk` present (Bob has `recv_count = 1` and `recv_ratchet_pk` set, but his all-zero key is `send_epoch_key`, not `recv_epoch_key`); guard 23 requires `send_count > 0` (Bob has `send_count = 0`). The all-zero `send_epoch_key` is safe because `ratchet_pending = true` guarantees it will be replaced by `KDF_Root` output during the first `encrypt()` call's KEM ratchet step — it is never used for key derivation. A reimplementer adding their own all-zero-key sanity check must exclude this initial state or Bob's first serialization will be rejected.
epochatu64::MAX→ChainExhausted(next serialization would overflow). UnlikeChainExhaustedfromencrypt()(§6.5), this is not session-fatal: the in-memory ratchet state is NOT consumed — it remains valid and can continue sending and receiving. However, the session is permanently un-serializable:epochis incremented only byto_bytes()and reset only byreset()— no KEM ratchet step or other operation resets it. The only recovery isreset()followed by a new LO-KEX exchange. (In practice,u64::MAXserializations is unreachable — included for completeness.) This guard fires on bothto_bytesandfrom_bytes:from_bytesalso rejects a storedepochfield equal tou64::MAXwithChainExhausted. Loading such a blob would produce a session that can send and receive in memory but immediately returnsChainExhaustedon the nextto_bytescall — permanently un-persistable without a single message processed. Rejecting at deserialization prevents this "zombie session" state. A reimplementer who checks guard 24 only on the serialization path creates a session that appears to load successfully but can never be saved.- All-zero
root_key→InvalidData(session was zeroized/reset — not a valid deserializable state). Constant-time comparison viact_eq.
Anti-rollback deserialization: The primary deserialization entry point is from_bytes_with_min_epoch(blob, min_epoch), which enforces guard 12: the blob's epoch field must be strictly greater than min_epoch (i.e., epoch > min_epoch, not >=). This prevents storage-layer replay attacks where an adversary substitutes an older serialized blob to rewind the ratchet to a prior state — which would re-derive the same epoch keys and restart counters from their prior values, causing catastrophic AEAD nonce reuse.
The epoch counter is a u64 incremented by to_bytes() each time the state is serialized. It is a persistence-layer counter independent of cryptographic epochs (KEM ratchet steps) — multiple serializations may occur within a single cryptographic epoch. Consequently, multiple valid blobs with different epoch values can encode identical cryptographic state (same keys, counters, and recv_seen sets). The anti-rollback mechanism prevents loading a blob with an older persistence epoch, but does not prevent two blobs encoding the same cryptographic state from coexisting on disk. The protection is "no blob older than the last loaded" — not "no blob with a prior cryptographic state."
Caller obligations:
-
Store
min_epochwith independent integrity. The caller must persist the last-seen epoch value in a location whose integrity is independent of the ratchet blob itself. If an adversary who can substitute the ratchet blob can also substitutemin_epoch, the anti-rollback mechanism is defeated. Suitable approaches: a separate authenticated store, a monotonic counter in secure hardware, or a key-value store where each entry is independently authenticated. -
Atomic commit of blob + min_epoch. The
min_epochupdate and the new blob must be committed atomically (or in the correct order) to survive crashes. The safe pattern: afterto_bytes()returns(blob_N, epoch_N), persistblob_Nfirst, then updatemin_epochtoepoch_N - 1. This ensures the current blob is always reloadable:epoch_N > epoch_N - 1holds. Updatingmin_epochtoepoch_N(the blob's own epoch) before persisting the next blob is dangerous: if the application crashes before the nextto_bytes(),blob_Nis no longer loadable (epoch_N > epoch_Nis false) and the session is permanently lost. The invariant is:stored_min_epoch < epoch_of_current_blob, so the current blob always passes guard 12. -
First-session bootstrap. For a newly established session (first deserialization ever), the caller should pass
min_epoch = 0. The initialto_bytes()call setsepoch = 1, which satisfies1 > 0. Subsequent deserializations pass the stored epoch from the prior successful load. -
Per-session tracking. Each ratchet session requires its own
min_epochvalue — sessions are independent and their epoch counters are unrelated. The caller must map session identifiers to their respective min_epoch values. The stable session identity is the(local_fp, remote_fp)pair — the two 32-byte fingerprints supplied toinit_alice/init_bob. These are invariant across serialization/deserialization cycles and survive restarts; application-layer connection IDs, database row IDs, or in-process object pointers do not survive restarts and MUST NOT be used as per-session min_epoch keys. A globalmin_epochshared across sessions enables cross-session substitution: if session A hasepoch = 50and session B hasepoch = 30, an attacker who can write to the storage layer can replace session B's blob with session A's blob — the globalmin_epoch(set to 29 from B's last load) accepts A's epoch 50, and the application now has session A's ratchet state in session B's slot, causing messages to B's peer to be encrypted under A's keys (undecryptable by B's peer, but revealing A's ratchet state to B's storage layer). -
Handle
ChainExhaustedfromto_bytes(). Ifto_bytes()returnsChainExhausted(epoch atu64::MAX), the session is permanently un-serializable. The caller must treat this as session-fatal for persistence purposes: discard the session and establish a new one via LO-KEX (§5). The in-memory ratchet remains functional for sending and receiving, but any crash or restart without a persisted blob loses the session state. Callers SHOULD checkcan_serialize()before long-running operations and proactively re-establish the session rather than waiting forto_bytes()failure. In practice,u64::MAXserializations is unreachable. -
to_bytesis ownership-consuming — serialize before invalidating. In Rust,to_bytes(self)enforces this at the type level (the ratchet is moved into the function). In languages without ownership semantics (C, Go, Python), the implementation must complete serialization of the entire blob before invalidating the handle. An implementation that nulls the handle pointer (or frees the backing memory) before finishing serialization leaves the caller with neither the original state nor a complete blob — a total session loss on crash. The safe pattern: serialize all fields into the output buffer, then zeroize and free the internal state, then return the blob. The CAPI (soliton_ratchet_to_bytes) follows this pattern: the output buffer is fully written before the handle is freed.
from_bytes / from_bytes_with_min_epoch error table (all callers MUST handle all three error types):
| Error | Condition | Recovery |
|---|---|---|
UnsupportedVersion |
Version byte ≠ 0x01 (guard 1) | Reject blob; re-establish via LO-KEX. No migration path — old-version blobs are permanently unreadable. For version bytes > 0x01 (future versions), upgrading to an implementation that supports that version may enable recovery. |
InvalidData |
Any guard 2-11, 13-23, or 25 fires; or input too large | Blob is structurally invalid or corrupt; re-establish via LO-KEX |
ChainExhausted |
epoch == u64::MAX (guard 24) |
Stored epoch at u64::MAX cannot be re-serialized (to_bytes overflows on epoch + 1). States with stored epoch u64::MAX - 1 are accepted but non-serializable (can_serialize() returns false, to_bytes returns ChainExhausted); re-establish via LO-KEX |
Callers matching only InvalidData | UnsupportedVersion will misclassify ChainExhausted: guard 24 fires for epoch == u64::MAX, returning ChainExhausted from from_bytes, not InvalidData. A caller who pattern-matches only the first two variants will treat ChainExhausted as an unhandled/unexpected error, potentially panicking or applying the wrong recovery action. ChainExhausted from from_bytes requires the same recovery as InvalidData (re-establish via LO-KEX), but the caller must handle it explicitly to avoid the default/unmatched case.
Blob size bound: The maximum valid ratchet blob is bounded by the wire format — with 65,535 entries in both recv_seen sets (guard 14 rejects num_seen >= MAX_RECV_SEEN (65536), so the maximum accepted count is 65,535), the blob reaches approximately 530 KB. The dominant term is two recv_seen sets × 65,535 entries × 4 bytes per u32 = 524,280 bytes; the remaining fixed fields (two X-Wing public keys at 1,216 bytes each, the X-Wing secret key at 2,432 bytes, two 32-byte epoch keys, fingerprints, and header fields) add approximately 5 KB. CAPI implementations apply a 1 MiB cap on from_bytes input as defense-in-depth against oversized inputs (tighter than the general 256 MiB CAPI cap, since ratchet blobs have a known bounded size). Reimplementers building their own deserialization entry point should apply a similar cap. Minimum valid blob size is 195 bytes — all optional fields absent (0x00 markers, 5 bytes), both recv_seen counts zero (2 × 4 = 8 bytes), with fixed mandatory fields (version 1 B + epoch 8 B + root_key 32 B + send_epoch_key 32 B + recv_epoch_key 32 B + local_fp 32 B + remote_fp 32 B + send_count 4 B + recv_count 4 B + prev_send_count 4 B + ratchet_pending 1 B = 182 bytes): 182 + 5 + 8 = 195 bytes. Any blob shorter than 195 bytes cannot represent a valid ratchet state and MUST be rejected with InvalidData. The reference implementation rejects such blobs during parsing — the field reader exhausts the buffer and returns InvalidData mid-parse rather than via an upfront length guard. Reimplementers who want a fast-reject pre-check SHOULD add an explicit if blob.len() < 195 { return Err(InvalidData) } guard before beginning field-by-field parsing; the reference implementation relies on parser exhaustion for equivalent behavior. The 195-byte floor is a format floor, not a state floor: no valid ratchet session ever produces a blob this small in practice. Alice's initial state (after ratchet_init_alice) includes send_ratchet_sk (2,432 bytes) and send_ratchet_pk (1,216 bytes), making her minimum blob approximately 3,849 bytes. Bob's initial state (after ratchet_init_bob) includes recv_ratchet_pk (1,216 bytes) and peer_ek (already absorbed), making his minimum exactly 1,413 bytes (see F.21 for the full field-by-field breakdown). The 195-byte check is a fast-reject sanity guard — implementations should not size parsing buffers based on it.
The convenience function from_bytes(blob) (without min_epoch) exists for use cases where anti-rollback is managed externally or is inapplicable (e.g., in-memory round-trip during migration). It is equivalent to from_bytes_with_min_epoch(blob, 0) and provides no rollback protection. Implementations that persist ratchet state MUST use from_bytes_with_min_epoch. from_bytes is deprecated at the Rust API level (using it produces a compiler warning). Binding authors SHOULD NOT expose it as a public API — expose only from_bytes_with_min_epoch and let callers pass min_epoch = 0 explicitly when they want no rollback protection.
Anti-rollback failure recovery: When from_bytes_with_min_epoch rejects a blob due to epoch rollback (guard 12), the session is permanently broken — the persisted state has been rewound to a prior epoch, which would cause AEAD nonce reuse if accepted. The application MUST discard the session and initiate a new LO-KEX exchange (§5). Reimplementers MUST NOT retry with an older blob, silently fall back to stale in-memory state, or attempt to "repair" the epoch counter. The only safe recovery path is full session re-establishment.
InvalidData ambiguity from from_bytes_with_min_epoch: Both epoch-rollback rejection (guard 12) and structural blob corruption (guards 2-11, 13-23, 25) return InvalidData. A caller who needs to distinguish "blob is stale, need new KEX" from "blob is corrupted, check backups" cannot do so from the error alone. The recovery action is identical in both cases — discard the session and establish a new one via LO-KEX — so the ambiguity has no practical consequence.
Diagnostic pattern for higher-level APIs: Appendix E and §13.5 note that from_bytes (the no-min-epoch variant) is deprecated and SHOULD NOT be exposed as a public API in higher-level bindings. A reimplementer who needs to distinguish epoch-rollback from structural corruption without exposing from_bytes should implement inspect_version(blob: &[u8]) -> Result<u8, Error> — a function that reads only the first byte of the blob and returns the version without parsing or validating any other field. This is not a full deserialization; it carries no mutation risk. If inspect_version returns UnsupportedVersion and the byte is 0x01, the caller knows the blob is current-version (so not a version mismatch) but epoch-rollback can then be confirmed by comparing blob[1..9] (the epoch u64 BE) against min_epoch. This diagnostic should be implemented at the application layer using the raw blob bytes, not by calling the library's deprecated from_bytes. from_bytes is safe to call for purely diagnostic purposes (it is read-only and never mutates external state), but exposing it publicly encourages callers to use it as a primary deserialization path, bypassing anti-rollback protection.
6.9 Implementation Notes
Requirements for implementers:
- Test vectors: Ship comprehensive vectors covering: in-order, out-of-order, KEM ratchet step, previous-epoch message, duplicate detection.
- State serialization: Round-trip serialize/deserialize ratchet state and verify continued operation.
- Fuzzing: Fuzz the decryption path with random headers. Verify no panics, no state corruption.
- Session reset as escape hatch: On unrecoverable decryption failure, fall back to session reset (§6.10). Lost messages are unavoidable but preferable to permanent communication failure.
- Defensive validation: Before each operation, check invariants (counters non-negative, expected keys present, epoch keys non-zero). Violation → trigger session reset.
6.10 Session Reset
reset(state): Zeroizes all key material (root_key, send_epoch_key, recv_epoch_key), drops all optional keys (send_ratchet_sk/pk, recv_ratchet_pk, prev_recv_epoch_key, prev_recv_ratchet_pk), resets all counters to 0, clears recv_seen and prev_recv_seen, sets ratchet_pending = false, and resets epoch to 0. The all-zero root_key serves as the liveness sentinel — subsequent encrypt/decrypt calls detect the dead session via constant-time comparison (§6.5, §6.6). The fingerprints (local_fp, remote_fp) are also zeroized to prevent information leakage from the dead state. Caller obligation: after reset(), the ratchet handle no longer identifies which peer this session belonged to — both fingerprints are zero. Applications that need to associate the handle with a peer identity after a reset (e.g., to display the peer name or verify a new LO-KEX exchange) MUST store the fingerprints independently (in application state) before calling reset(). The library cannot preserve them across reset.
Destructor zeroization obligation: Implementations MUST zeroize all key material when the ratchet state object is deallocated (destructor, finalizer, or equivalent), not only on explicit reset() calls. In Rust, this is achieved by implementing Drop to call reset() — any abandoned state (error path, lost reference, scope exit) is automatically zeroized. Non-Rust implementations must arrange equivalent behavior: a C implementation must zeroize in the free function, a Go implementation in a finalizer, a Python implementation in __del__ (with a note that CPython finalizer timing is non-deterministic — consider explicit close() as the primary path). Without destructor zeroization, every error path that drops a session object leaks key material to the process heap.
reset() followed by to_bytes() produces a blob that from_bytes_with_min_epoch rejects for any min_epoch ≥ 1: reset() sets epoch = 0. to_bytes increments the epoch counter before writing it (§6.8), so a reset state serializes with epoch = 1 in the blob. from_bytes_with_min_epoch(blob, min_epoch) requires blob.epoch > min_epoch (§6.8 guard 12). If the application has previously persisted a blob from an active session with min_epoch = N (where N ≥ 1), loading the reset-state blob with that same min_epoch fails: 1 > N is false for any N ≥ 1. This is correct behavior — a reset session is cryptographically equivalent to a new session, not a continuation of the old one; the old min_epoch correctly rejects it. Application implication: after calling reset(), the application MUST treat the session as a new session (no persisted min_epoch applies). If the application stores the old min_epoch persistently and uses it for all subsequent from_bytes_with_min_epoch calls, the reset session can never be loaded after its first successful to_bytes. The correct recovery path after reset() is to discard the old min_epoch store and establish a new LO-KEX session, not to attempt to serialize and reload the reset state under the old min_epoch.
reset() MUST NOT acquire locks or reentrancy guards: reset() is called from the Drop implementation (destructor), which executes whenever the ratchet state is deallocated — including on error paths that may occur while holding internal session locks. If reset() itself attempts to acquire a lock or reentrancy guard that is already held by the calling context, it will deadlock. reset() must be callable from any context, including from within a failed encrypt() or decrypt() call. The implementation must ensure that the reset() code path is a simple zeroization sequence with no blocking operations, no mutex acquisitions, and no calls to any function that could itself block or fail. In Rust, the Drop implementation that calls reset() satisfies this by design — Rust's ownership model prevents calling reset() on a borrowed-while-mutating object. In C/Go/Python, where the equivalent "destructor" is called explicitly or via finalizer, implementors must audit the reset() call path for lock acquisitions.
When ratchet state is unrecoverable (Protocol Spec §12.13):
- Both parties discard all ratchet state for the peer.
- Resetting party fetches fresh pre-keys → performs new LO-KEX.
- New session is cryptographically independent (new EK, new shared secrets, new state).
- Messages encrypted under the old session that were not yet delivered become permanently undecryptable. This is unavoidable.
- Verification phrase (§9) is unchanged (depends only on IKs) — confirms identity continuity.
6.11 Bandwidth
| Per same-direction message | Per ratchet step |
|---|---|
| ~1216 B (ratchet_pk always in header) | ~2336 B (ratchet_pk + kem_ct; raw KEM fields only; full header = 2,347 B per Appendix C) |
The full ratchet public key is included in every message header so the recipient always knows the sender's current ratchet key. This provides an implicit consistency check.
Exact encoded sizes: The approximate values above reflect only the dominant fields. The complete encode_ratchet_header output (§7.4) includes the has_kem_ct flag (1 byte), counter n (4 bytes), and previous-epoch count pn (4 bytes) in addition to the public key and ciphertext. Exact values: 1,225 bytes without KEM ciphertext (1216 + 1 + 4 + 4), 2,347 bytes with KEM ciphertext (1216 + 1 + 2 + 1120 + 4 + 4) — see Appendix C (encode_ratchet_header). The table above uses "~" because those sizes reflect only the dominant network cost relative to payload size; the actual wire overhead is the Appendix C values.
6.12 Voice Call Key Derivation
Call encryption key material for E2EE voice calls is derived from the ratchet root key and an ephemeral X-Wing shared secret exchanged during call signaling. The ephemeral KEM provides forward secrecy independent of the ratchet state — if the root key is later compromised (before the next KEM ratchet step), the call content remains confidential.
Group calls use the same mechanism: each participant derives call keys with every other participant via their existing pairwise ratchet sessions. The server acts as an SFU (Selective Forwarding Unit), routing encrypted media packets without decryption. Specifically: the call_id is shared across all participants (the initiator generates it once and distributes it via ratchet-encrypted signaling messages to each participant). The ephemeral KEM exchange is per-pair — each pair of participants performs an independent CallOffer/CallAnswer exchange over their pairwise ratchet session, producing a unique kem_ss per pair. Each pair therefore derives independent call keys from their unique (root_key, kem_ss, call_id) triple. The shared call_id provides a common call identifier for the application layer; it does not weaken key independence because kem_ss differs per pair.
Call Setup Protocol
-
Initiator generates
call_id = random_bytes(16)and an ephemeral X-Wing keypair(ek_pub, ek_sk) = XWing.KeyGen(). SendsCallOffer { call_id, ek_pub }to the peer as a ratchet-encrypted message. call_id MUST be unique per call — reusing acall_idwith the sameroot_keyandkem_ssproduces identical HKDF inputs and therefore identical call keys. The 128-bit random generation provides collision resistance (~2⁻⁶⁴ birthday bound), but implementations MUST NOT use predictable or sequential call IDs.ek_publifecycle:ek_pubis a public key — it is non-secret, transmitted inCallOffer, and can be retained or discarded after transmission without security consequence.ek_skis secret and MUST be retained by the initiator untilCallAnswerarrives (it is needed for decapsulation) and MUST be zeroized immediately afterXWing.Decapscompletes — or on call rejection/cancellation/timeout if noCallAnswerever arrives. The initiator does not retainek_pubfor any cryptographic purpose after transmission; the responder holds it for KDF_Call's info field (which only uses fingerprints — see §6.12 HKDF derivation). Neither party usesek_pubafterderive_call_keysreturns. -
Responder encapsulates to the ephemeral public key:
(ct, kem_ss) = XWing.Encaps(ek_pub). SendsCallAnswer { call_id, ct }as a ratchet-encrypted message.XWing.Encapsconsumes CSPRNG randomness (ML-KEM encapsulation draws randomness for the ciphertext); at the CAPI level, CSPRNG failure aborts the process (§13.2). At the Rust API level, CSPRNG failure returnsInternal(structurally unreachable on standard OSes — see §6.5). TheCallAnswerMUST NOT be sent if encapsulation fails. -
Initiator validates the
CallAnswercall_idmatches theCallOffercall_idbefore proceeding — mismatched call IDs indicate a confused-deputy or replay attack and MUST be rejected. Then decapsulates:kem_ss = XWing.Decaps(ek_sk, ct). Zeroizesek_skimmediately. IfCallAnswernever arrives (peer rejects, times out, or network failure),ek_skmust still be zeroized — the application layer MUST zeroize the ephemeral secret key on call rejection, timeout, or cancellation, not only on successful decapsulation. Failure to do so leaves a decapsulation key in memory indefinitely, recoverable via memory scanning. -
Both parties derive call keys:
fp_lo, fp_hi = sort(local_fp, remote_fp) // canonical order: lower first
// Equal fingerprints are rejected upstream
// (guard 19 at deserialization, and init_alice/init_bob
// reject local_fp == remote_fp) — no tiebreaker branch
// is needed here.
call_keys = HKDF(
salt = root_key,
ikm = kem_ss || call_id, // 32 + 16 = 48 bytes, raw concatenation (no length prefixes)
info = "lo-call-v1" || fp_lo || fp_hi, // 10 + 32 + 32 = 74 bytes, raw concatenation (no length prefixes)
len = 96
)
key_a = call_keys[0..32]
key_b = call_keys[32..64]
chain_key = call_keys[64..96]
Initial key semantics (step 0): key_a and key_b from derive_call_keys are immediately usable for the first rekeying interval — step_count starts at 0 and the initial keys are the step-0 keys. The first call to AdvanceCallChain produces step-1 keys. Implementations MUST NOT call AdvanceCallChain before using the initial keys — both parties would begin at step 1, producing compatible keys, but a party that calls advance before first use and a party that does not would be off by one with no error or diagnostic.
- Role assignment: The party with the lexicographically lower identity fingerprint (unsigned byte-by-byte comparison, left to right) uses
key_aas their send key andkey_bas their recv key. The other party reverses the assignment.
call_id is an opaque 16-byte blob — no UUID normalization: call_id is raw bytes concatenated directly into the HKDF IKM. No byte-order conversion, UUID formatting, or canonical representation is applied. Two implementations that both generate UUIDs MUST concatenate the same raw byte representation — e.g., if both use RFC 4122 little-endian UUID bytes (as in .NET Guid.ToByteArray()), both must concatenate exactly those bytes. An implementation that converts to network byte order or uses a different UUID serialization will silently derive different call keys. The safest approach: generate call_id = random_bytes(16) and treat those 16 bytes as opaque throughout the call lifecycle, never reinterpreting them as a UUID.
No length prefixes in info or IKM: Neither the info nor the ikm fields use length-prefixed encoding. Unlike KDF_KEX (§5.4), which applies len(x) || x format to each info component, derive_call_keys concatenates all fields raw. A reimplementer who applies the §5.4 convention to info would prepend \x00\x0a, \x00\x20, \x00\x20 before each component — producing a 80-byte info input instead of 74, silently incompatible call keys. The fixed sizes of all three info fields ("lo-call-v1" = 10 bytes, each fingerprint = 32 bytes) make length prefixes redundant for disambiguation; their omission is intentional, not an oversight.
Why call_id is in IKM, not info: call_id goes in IKM (alongside kem_ss) as defense-in-depth against KEM randomness failure. If the KEM's random number generator is compromised or biased, a unique call_id in IKM introduces variability into the HKDF extraction phase — different calls produce different extracted keys even if kem_ss is identical. Fingerprints go in info because their secrecy is not required (they are public values providing domain separation). Moving call_id to info would produce a subtly weaker construction: with a non-random KEM, all calls between the same pair would derive identical keys regardless of call_id.
kem_ss is zeroized immediately after HKDF. The 48-byte ikm concatenation buffer (kem_ss || call_id) also contains a copy of kem_ss and MUST be zeroized independently — zeroizing kem_ss alone leaves this copy in memory. In Rust, the ikm buffer is a plain [u8; 48] (Copy type, not a Zeroizing wrapper) and is explicitly zeroized via ikm.zeroize() immediately after HKDF — wrapping a Copy array in Zeroizing would receive a bitwise copy and leave the original on the stack, so the call-site zeroization is required; C/Go/Python implementations MUST explicitly zeroize the 48-byte concatenation buffer before freeing it (e.g., memset_s(ikm, 0, 48) in C), not just the 32-byte kem_ss. Contrast with §5.4's multi-paragraph IKM zeroization rationale — the same obligation applies here because ikm contains a bitwise copy of kem_ss. The ratchet state is not modified — root_key is read but not advanced. Multiple concurrent calls can each invoke derive_call_keys independently; since root_key is read-only, these derivations are idempotent with respect to the ratchet and do not interfere with each other or with ongoing message encryption. derive_call_keys reads root_key directly from live ratchet state via the RatchetState::derive_call_keys(&self, kem_ss, call_id) method, which does not accept root_key as a parameter. The core library also exports a standalone call::derive_call_keys(root_key, kem_ss, call_id, local_fp, remote_fp) function that accepts root_key explicitly — this exists for CAPI use (where the ratchet handle provides root_key and fingerprints internally) and for unit testing. Binding authors and reimplementers SHOULD use the RatchetState method, not the standalone function. The standalone function is safe only when the caller guarantees the §6.12 protocol requirement (no KEM ratchet step between signaling and derivation); the method enforces this implicitly by reading live state at call time. A reimplementer who designs the function to accept a root_key parameter (allowing the caller to snapshot root_key at CallOffer time and pass it later) creates an epoch-sync hazard: if a KEM ratchet step fires between CallOffer and derive_call_keys, the snapshot holds the pre-ratchet root_key while the peer's live state has already advanced — producing incompatible call keys with no diagnostic. The §6.12 protocol requirement (no KEM ratchet step between signaling and derivation) makes the live-read safe; a parameter-passing design would require the caller to enforce the same invariant externally.
Root key epoch sync: Both parties must call derive_call_keys at the same ratchet epoch — i.e., with the same root_key snapshot. A KEM ratchet step between sending CallOffer and calling derive_call_keys (or between receiving CallAnswer and deriving) advances root_key on one side, producing incompatible call keys with no error or diagnostic (HKDF succeeds, but the other party's derivation uses the old root_key). Protocol requirement (normative): no KEM ratchet step (triggered by sending or receiving a ratchet message with a new ratchet_pk) may occur between the CallOffer/CallAnswer exchange and the derive_call_keys call on either side. Implementations MUST follow this ordering: initiator MUST call derive_call_keys immediately after sending CallOffer and before processing any further ratchet messages; responder MUST call derive_call_keys immediately after sending CallAnswer and before processing any further ratchet messages. This is not advisory — violating this order produces silently incompatible call keys with no error or diagnostic on either side.
Enforcement window boundaries and concurrent ratchet-step messages: The enforcement window opens when CallOffer is sent (initiator) or received (responder) and closes when derive_call_keys is called on the same side. The window is bounded by the round-trip time of the signaling exchange — in practice, tens to hundreds of milliseconds. A ratchet message that arrives during this window and triggers a KEM ratchet step (because it carries a new ratchet_pk) MUST be queued and not processed until after derive_call_keys is called. The implementation MUST NOT decrypt the message or advance root_key while the enforcement window is open. The queue depth is bounded by the number of ratchet messages that can arrive during a single round trip — in practice, 0-5 messages. If a ratchet-step message is queued for more than a configurable timeout (LO Protocol defines the signaling timeout; typical value: 5 seconds), the call setup is considered failed and all signaling state is discarded. The definition of "enforcement window" and the specific queueing mechanism are deferred to the LO Protocol Specification. soliton enforces only the invariant that root_key MUST NOT advance between CallOffer/CallAnswer and derive_call_keys; the protocol layer is responsible for implementing the queueing and timeout behavior.
Signaling Messages
All signaling messages are encrypted via the existing LO-Ratchet session:
CallOffer { call_id, ek_pub }— initiator → peerCallAnswer { call_id, ct }— peer → initiatorCallHangup { call_id }— either directionCallReject { call_id }— peer declines
These are application-layer message types. soliton provides only the key derivation for signaling; signaling message encoding and transport are application concerns.
Frame encryption is also application-layer: soliton delivers two raw 32-byte symmetric keys (key_a and key_b) and does not define a frame cipher, nonce scheme, or frame AAD structure. The application is responsible for choosing an AEAD algorithm for media frames, constructing per-frame nonces (e.g., from frame sequence numbers), defining what AAD (if any) to include in frame authentication, and handling frame loss or reordering. A common approach is XChaCha20-Poly1305 with a 64-bit monotonically increasing frame counter as the nonce (zero-padded to 24 bytes); however, soliton does not mandate this. The keys produced by derive_call_keys are suitable inputs to any 256-bit AEAD; the choice of frame AEAD is outside the scope of this specification.
Intra-Call Rekeying
The call chain key supports periodic rekeying for forward secrecy within the call:
function AdvanceCallChain(chain_key):
// step_count is checked before derivations (guard fires when step_count >= 2²⁴).
// All three HMAC derivations execute only when the guard does not fire.
key_a' = HMAC-SHA3-256(chain_key, [0x04]) // single-byte data
key_b' = HMAC-SHA3-256(chain_key, [0x05]) // single-byte data
chain_key' = HMAC-SHA3-256(chain_key, [0x06]) // single-byte data
// Zeroize old chain_key
step_count += 1 // incremented AFTER all three derivations, on the success path only;
// not incremented on the exhaustion path (step_count stays at 2²⁴
// on every post-exhaustion call — see exhaustion pseudocode below).
// Role assignment (key_a' → send or recv) is preserved from initial derivation.
// Mechanism: derive_call_keys returns a lower_role: bool computed from fingerprint
// comparison (§6.12 step 5). The caller stores this bool and passes it to every
// AdvanceCallChain call: lower_role=true → key_a' is the send key, key_b' is recv;
// lower_role=false → key_b' is send, key_a' is recv. A reimplementer who does not
// persist lower_role gets swapped send/recv keys on every advance, with no error.
return (key_a', key_b', chain_key')
On exhaustion (step_count >= 2²⁴), all three key fields are zeroized before returning:
Zeroize key_a
Zeroize key_b
Zeroize chain_key
return ChainExhausted
On exhaustion, all three key fields (key_a, key_b, chain_key) are zeroized — not just chain_key.
Each advance produces fresh call encryption keys and a new chain key. The old chain key and call keys are zeroized. Compromise of a later call key does not reveal earlier media segments.
step_count is not an HMAC input: AdvanceCallChain takes only chain_key as input; step_count does not feed into the HMAC derivation. It is a pure exhaustion counter — tracking how many advances have occurred to enforce the 2²⁴ limit. Why this is safe: chain_key itself advances monotonically via HMAC one-way function at each step, providing implicit domain separation — step N's output is independent of step N−1's because it is derived from a different key (the previous step's output). In contrast, KDF_MsgKey (§6.3) reuses the same epoch_key for every message in an epoch, making the counter essential to distinguish per-message derivations. AdvanceCallChain does not reuse the key: each step produces a fresh chain_key' that is HMAC-derived from the prior chain_key, so including step_count in the data argument would be redundant. A reimplementer familiar with KDF_MsgKey must not apply the same pattern here: including step_count in the data argument would produce a different chain key at every step N > 0, making every derived call key incompatible with the reference implementation despite producing no error.
The rekey interval is an application-layer decision (e.g., every 30 seconds or every N encrypted frames). soliton provides the advance() primitive; the application controls when to call it. Both parties MUST advance the chain in lockstep — mismatched step_count values produce incompatible keys with no error or diagnostic. The synchronization mechanism is application-layer (e.g., include step_count in encrypted media frame headers; the receiver advances to match before decrypting). When the receiver's step_count is behind the sender's, it must call advance() sequentially N times to catch up — there is no shortcut (each step requires the previous chain key as input). A reasonable fast-forward tolerance is application-specific, but implementations SHOULD cap the maximum gap (e.g., 1000 steps) and treat larger gaps as session corruption rather than attempting a potentially expensive sequential catch-up. Once desynchronization is detected (receiver cannot decrypt at any plausible step_count offset within the tolerance window), the call's key material is irrecoverable — a new call must be established via the §6.12 setup protocol.
Call chain exhaustion: AdvanceCallChain has a hard limit of 2²⁴ (16,777,216) advances. The internal step_count starts at 0 and is checked (step_count >= 2²⁴) before the HMAC derivations and before any increment — the last permitted advance occurs when step_count = 2²⁴ − 1, after which step_count increments to 2²⁴ and the next call returns ChainExhausted. Fencepost note: at step_count = 2²⁴ − 1, the guard (2²⁴ − 1) >= 2²⁴ is false, so all three HMAC derivations execute and step_count increments to 2²⁴. At step_count = 2²⁴, the guard 2²⁴ >= 2²⁴ is true, so the guard fires first — no derivations run, step_count is NOT incremented further, and all key material is zeroized before returning ChainExhausted. A reimplementer who places the increment before the guard (step_count += 1; if step_count > 2²⁴) exhausts one step earlier (last advance at step_count = 2²⁴ − 2). A reimplementer who places the increment after the guard but also after the derivations, on the same branch that returns the keys, must ensure that guard-fires-and-no-increment and derivations-succeed-and-increment are handled by separate code paths — conflating them would increment step_count to 2²⁴ + 1 after the last permitted advance, causing step_count to wrap if stored in a u32 (though the reference implementation uses a u32 with wrapping prevented by the guard). On exhaustion, all call key material (key_a, key_b, chain_key) is zeroized — the call's forward-secrecy chain is permanently terminated. A new call must be established via §6.12's setup protocol. The limit prevents counter overflow in the internal chain advancement and bounds the total key material derivable from a single call chain. At a 30-second rekey interval, 2²⁴ advances corresponds to ~16 years of continuous call — the limit is not reachable in practice but is enforced as defense-in-depth.
step_count MUST be stored as a u32 or wider: A narrower type (u8, u16, u24) wraps before reaching 2²⁴, silently disabling the exhaustion guard. For example, a u24 implementation wraps to 0 after 16,777,216 advances — subsequent calls pass the guard 0 >= 2²⁴ = false and continue deriving keys indefinitely. The reference implementation uses a u32 (which can represent values up to ~4.3 × 10⁹, well above 2²⁴ = 16,777,216). Reimplementers MUST use a type that can represent 2²⁴ without wrapping.
ChainExhausted from advance() — exhausted handle is NOT auto-freed; caller MUST free it: Key material is zeroized on exhaustion, but the CallKeys allocation is NOT deallocated. The handle remains allocated and must be explicitly freed by the caller (soliton_call_keys_free in the CAPI; Rust's Drop via the normal ownership path). Failing to free the handle leaks the (now-zeroed) allocation. soliton.h OWNERSHIP note conflict: The generated header may carry a comment stating that after ChainExhausted, "all keys are zeroized — handle is dead." The phrase "handle is dead" means the handle is no longer usable for advance() — it does NOT mean the handle was auto-freed. Specification.md is normative: the handle is live, key material is zeroed, and the caller is responsible for freeing it. A binding author who reads "handle is dead" as "already freed" will double-free the handle. The correct action after ChainExhausted from advance() is: (1) record that the call session is exhausted, (2) free the handle via the standard free function, (3) establish a new call via derive_call_keys().
CAPI handle lifetime: Zeroization of key material does not deallocate the call chain handle. CAPI callers MUST still call the handle's destroy/free function after receiving ChainExhausted — failure to do so leaks the (now-zeroed) allocation. The handle remains valid for destruction but invalid for further advance() calls.
Post-exhaustion idempotency: After the first ChainExhausted, every subsequent call to advance() also returns ChainExhausted and unconditionally zeroizes key material. Because the keys were already zeroed on the first exhaustion, the re-zeroization is a no-op, but implementations MUST NOT guard against re-zeroization ("skip if already exhausted") — the unconditional behavior ensures that a caller who ignores the first ChainExhausted cannot obtain stale key material from a later call. step_count is not reset; it stays at 2²⁴. step_count is an internal counter — it is NOT exposed via the Rust API or CAPI. The only externally observable signal of exhaustion is the ChainExhausted return value from advance(). Callers MUST check the return value; there is no way to query exhaustion state without calling advance().
Security Properties
Input validation: derive_call_keys rejects all-zero root_key (dead session — liveness sentinel, constant-time check), all-zero kem_ss (degenerate KEM output — cryptographically implausible but structurally guarded, constant-time check), all-zero call_id (uninitialized identifier, variable-time — call_id is non-secret), and local_fp == remote_fp (self-call — collapses role assignment, variable-time — fingerprints are public). All four checks return InvalidData. The equal-fingerprint check is critical: with equal fingerprints, the strict < comparison in role assignment evaluates to false for both parties, so both assign key_a as recv and key_b as send — symmetric key confusion where each party encrypts with the key the other expects to decrypt with. This is distinct from the ratchet's local_fp ≠ remote_fp guard (§6.8 guard 19), which protects AAD symmetry.
Call key secrecy: Requires both root_key and kem_ss. The root key is bound via HKDF salt; the ephemeral KEM shared secret is bound via IKM.
Epoch binding via root_key as HKDF salt: Including root_key as the HKDF salt binds call keys to the current ratchet epoch. Call keys derived at ratchet epoch E are independent of those at epoch E+1 for the same (kem_ss, call_id) triple — advancing the ratchet between two calls changes root_key, producing completely different call keys even if the same ephemeral KEM exchange is reused. A reimplementer who uses a fixed salt (e.g., an empty salt or a static label) instead of root_key removes this epoch isolation: all calls between the same pair with the same call_id derive identical keys regardless of ratchet epoch, making past call keys recoverable from any future epoch compromise.
Forward secrecy (ephemeral KEM): The ephemeral keypair is generated per call and zeroized after derivation. Later compromise of root_key does not reveal call content — kem_ss is no longer recoverable.
Defense-in-depth (post-quantum): root_key in the HKDF salt carries the ratchet's accumulated post-quantum security. If the ephemeral KEM is broken by a quantum computer, root_key still protects. If root_key is compromised classically, the ephemeral KEM still protects.
Intra-call forward secrecy: AdvanceCallChain is one-way (HMAC-based PRF). Old chain keys are zeroized.
No ratchet state mutation: The ratchet operates independently during calls. Text messages advance the ratchet as normal.
CallKeys is intentionally ephemeral — no serialization path: There is no to_bytes/from_bytes API for CallKeys. Call key material is not designed to survive process restarts or be persisted to storage. If a call is interrupted (network failure, OS suspend, process crash), the CallKeys handle is lost and the call's key material is unrecoverable. The correct response is to re-establish the call via the §6.12 setup protocol (derive_call_keys on the current ratchet state after a new CallOffer/CallAnswer exchange) — the resulting new call keys will be independent of the interrupted call's keys, providing per-call forward secrecy. A reimplementer who adds a serialization path for CallKeys (to survive restarts) undermines this forward-secrecy property — a leaked blob of serialized call key material recovers the call's media encryption keys without any KEM secrets.
6.13 Design Rationale: Per-Epoch vs Per-Message Forward Secrecy
LO-Ratchet provides forward secrecy at epoch granularity (per KEM ratchet step), not per message. This is a deliberate departure from the Signal Double Ratchet, which provides per-message forward secrecy via a sequential KDF chain.
What per-message forward secrecy protects against: An attacker who compromises the chain key at position N can derive message keys N+1, N+2, ... but not 0, 1, ..., N-1. This matters only if the attacker obtains the chain key but not the root key or ratchet secret key.
Why this threat model is unrealistic: The RatchetState struct contains the epoch key, root key, and ratchet secret key at adjacent memory addresses. The root key is strictly more powerful (it derives all future epoch keys). The ratchet secret key enables decapsulating all future KEM ratchets. Any memory compromise that extracts the epoch key — buffer overread, memory dump, side-channel attack — extracts these adjacent secrets with overwhelming probability. Per-message forward secrecy protects against an attacker who can surgically extract exactly 32 bytes from a known offset and nothing else. This is not a realistic attack.
What we gain by dropping it:
- O(1) out-of-order handling: Any message key is derivable directly from the epoch key and counter. No skip cache, no TTL expiry, no purge throttling.
- ~300 fewer lines of code: The skip cache was the most error-prone component (§6.9 in prior versions explicitly warned about this).
- Simpler serialization: No variable-length skip cache in the wire format.
- Reduced memory: No HashMap of 32-byte message keys (up to 3000 entries / ~96 KB). Duplicate detection uses 4-byte counters.
- No
TooManySkippederror: The skip-amplification DoS vector is eliminated entirely.
What we keep: Forward secrecy across epochs (KEM ratchet), break-in recovery (fresh KEM randomness), unique per-message keys (AEAD security), and a one-epoch grace period for late-arriving messages (via prev_recv_epoch_key).
Post-compromise security (PCS): PCS holds at epoch granularity. After an attacker compromises ratchet state at time t₁, a subsequent KEM ratchet step at time t₂ > t₁ with fresh (uncompromised) encapsulation randomness re-establishes confidentiality for the new epoch. The attacker's knowledge of state at t₁ does not yield epoch keys derived after the KEM ratchet step, because the fresh KEM shared secret is unknown to the attacker. Recovery depends on the sender's (encapsulator's) randomness being honest — the receiver contributes no fresh randomness to the KEM ratchet step (§14.11). For formal models: Corrupt(state, t₁) does not imply knowledge of epoch keys from a KEM ratchet step at t₂ > t₁, provided the encapsulator's CSPRNG is uncompromised at t₂.
PCS healing boundary — decapsulation time, not encapsulation time: PCS healing completes when the decapsulator successfully decrypts the first message in the new epoch, not when the encapsulator generates the KEM ciphertext. The encapsulator's fresh kem_ss is produced at t₂ (encapsulation), but recv_epoch_key on the decapsulator's side does not update until the first new-epoch message is successfully decrypted (at t₃ ≥ t₂). An adversary who compromises state at t₁, observes the KEM ratchet ciphertext at t₂, but suppresses delivery until t₃ can still decrypt all messages in the new epoch that are delivered before t₃ — because the decapsulator continues using the compromised recv_epoch_key until t₃. After t₃, the decapsulator holds the healed recv_epoch_key derived from the attacker-unknown kem_ss. For formal models: use Healed(session, t₃) where t₃ is the time of the first successful new-epoch decryption, not t₂.
Two KEM ratchet steps for complete PCS healing: A single KEM ratchet step derives a new recv_epoch_key unknown to the attacker — new-epoch messages are immediately protected. However, prev_recv_epoch_key (the compromised epoch key) persists through the first step, retained for the one-epoch late-message grace period. An adversary who compromises state immediately after the first KEM ratchet step can still read previous-epoch messages (via prev_recv_epoch_key). Full PCS healing — where the adversary retains no access to any compromised key material — requires two KEM ratchet steps: the first step rotates the compromised key into prev_recv_epoch_key; the second step overwrites prev_recv_epoch_key with the then-current epoch's key, permanently discarding the originally compromised material. For formal models: define FullyHealed(session, t₄) where t₄ is the time of the second successful new-epoch decryption (not just the first). The §14.17 cross-reference uses Healed(session, t₃) to mean "new epoch is healed"; formal models that also require the previous epoch's key to be gone MUST use FullyHealed(session, t₄).
PCS does NOT cover:
- Already-compromised epochs: Messages encrypted before the healing KEM ratchet step remain compromised. PCS is forward-looking — it restores confidentiality for future epochs, not retroactively for past ones.
- Compromised encapsulator randomness: If the attacker controls the sender's CSPRNG at the time of the KEM ratchet step, the fresh
kem_ssis known to the attacker and the step does not heal. Recovery requires at least one KEM ratchet step with honest randomness (§14.11). - Active attacker participating in the KEM exchange: If the attacker can substitute
ratchet_pkin a message header (man-in-the-middle on the message transport), the KEM encapsulation targets the attacker's key rather than the peer's. AEAD authentication prevents this in normal operation (the header is bound into the AAD), but a full state compromise at t₁ gives the attacker enough material to forge headers until the next honest KEM ratchet step. - One-directional sessions: PCS requires a direction change (the peer must send a message triggering a KEM ratchet step). A one-directional stream of messages never triggers a KEM ratchet and therefore never heals.
7. Symmetric Encryption
7.1 XChaCha20-Poly1305
Algorithm: XChaCha20-Poly1305 — the 24-byte-nonce variant of ChaCha20-Poly1305. This is NOT ChaCha20-Poly1305 (RFC 8439), which uses a 12-byte nonce. Go's golang.org/x/crypto/chacha20poly1305 package exposes both under similar names: chacha20poly1305.New constructs the 12-byte (RFC 8439) variant; chacha20poly1305.NewX constructs the 24-byte XChaCha20 variant. A reimplementer who uses New instead of NewX produces incompatible ciphertext silently — both accept any 256-bit key, and the error surfaces only as AeadFailed on the receiver. Always use the 24-byte nonce (XChaCha20) variant throughout soliton.
- Key: 256-bit message key from KDF_MsgKey.
- Tag: 128 bits (16 bytes), appended to ciphertext.
- Minimum valid ratchet ciphertext: 16 bytes (Poly1305 tag only, zero-length plaintext). Ciphertexts shorter than 16 bytes are rejected as
AeadFailed(notInvalidLength— see §12 error collapse). First-message encrypted payloads have a 40-byte minimum (24-byte nonce + 16-byte tag, §5.5 Step 6). First-message minimum enforcement:decrypt_first_messagealso returnsAeadFailed(notInvalidLength) for payloads shorter than 40 bytes — this collapses "too short to contain a valid nonce + tag" with "authentication failed" into a single error variant, preventing a distinguishing oracle: an attacker who could observeInvalidLengthvsAeadFailedwould learn whether the authentication attempt even ran (and at what byte offset parsing failed). A reimplementer who returnsInvalidLengthfor sub-40-byte first-message payloads breaks this oracle-collapse guarantee. aead_encryptfailure —AeadFailedon usize overflow: XChaCha20-Poly1305 encryption can returnAeadFailedonly when the plaintext length overflows internal length calculations (approximatelyplaintext.len() ≈ usize::MAX). This cannot occur with well-formed input bounded by the CAPI size cap (§13.4) or the storage/streaming chunk sizes. In practice,aead_encryptis infallible for any input that passes the upstream size guards. AnAeadFailedfromaead_encryptin production code indicates an integer overflow in the calling layer, not a cryptographic failure.- Constant-time by construction: ARX-based (add-rotate-xor); no table lookups, no data-dependent branches. No hardware acceleration required.
7.2 Nonce Construction
First message of a session (LO-KEX session init):
nonce = random_bytes(24) // Prepended to ciphertext payload
All subsequent messages (LO-Ratchet):
nonce[0..24] = 0x00{24} // MUST zero-initialize the entire buffer first
nonce[20..24] = big_endian_32(header.n)
Implementations MUST zero-initialize the entire 24-byte nonce buffer before writing the counter bytes into positions 20-23. In C, a stack-allocated uint8_t nonce[24] contains undefined (garbage) bytes unless explicitly zeroed — memset(nonce, 0, sizeof(nonce)) MUST precede the counter copy. In Go, var nonce [24]byte zero-initializes by language specification, but nonce := make([]byte, 24) from a pool-allocated slice may not. In Rust, let mut nonce = [0u8; 24] is zero-initialized by the type. A reimplementer who writes only nonce[20..24] = BE32(n) without zeroing positions 0-19 produces a garbage-contaminated nonce — the resulting AEAD key-nonce pair may or may not be unique across messages (depending on what was on the stack), producing non-deterministic AEAD failures on the receiver.
The counter nonce is not transmitted — recipient derives from header.n. Safe because each (msg_key, n) pair is unique: msg_key is derived from a unique epoch key and counter, and the epoch key changes on every KEM ratchet step.
An all-zero nonce (counter=0) is valid: The first message of every epoch uses header.n = 0, producing a 24-byte all-zero nonce [0x00 × 24]. This is intentional and correct — XChaCha20-Poly1305 specifies no restrictions on nonce content (unlike AES-GCM, which also accepts all-zero nonces). The security guarantee comes from msg_key uniqueness (unique per (epoch_key, counter) pair), not from nonce non-zero values. Implementations MUST NOT reject or guard against an all-zero nonce. Some AEAD libraries include a "null nonce protection" heuristic that rejects all-zero nonces as likely initialization failures — such protections MUST be disabled or bypassed for XChaCha20-Poly1305 ratchet encryption. A library that returns an error for a zero nonce would silently break decryption of every epoch's first message (n=0) in every post-ratchet epoch.
The counter occupies the last 4 bytes (20-23) of the 24-byte nonce, leaving bytes 0-19 as zero.
7.3 AAD Construction
First message (session init):
aad = "lo-dm-v1" // 8 bytes UTF-8
|| sender_fingerprint_raw // 32 bytes (raw SHA3-256, not hex)
|| recipient_fingerprint_raw // 32 bytes
|| encode_session_init(session_init) // variable, see §7.4
encode_session_init re-encoding obligation: encode_session_init MUST be called to reconstruct the canonical bytes from the parsed struct — using raw wire bytes directly is an error. Bob's obligation to re-encode is documented at §5.5 Step 3 and §13.4. The output MUST be byte-for-byte identical to Alice's encoding; any field normalization during decode that alters re-encoding causes silent AeadFailed.
Ratchet messages:
aad = "lo-dm-v1"
|| sender_fingerprint_raw
|| recipient_fingerprint_raw
|| encode_ratchet_header(ratchet_header)
This binds ALL header fields to the AEAD tag. Tampering invalidates the tag.
"lo-dm-v1" is concatenated bare — no length prefix: The 8-byte label "lo-dm-v1" is written directly into the AAD with || (byte concatenation), NOT as a length-prefixed len(x) || x field. Contrast with §5.4 HKDF info construction, where "lo-kex-v1" is also bare but explicitly noted as "raw 9-byte prefix (not length-prefixed)." The || operator in this spec always means raw byte concatenation; length prefixes are written explicitly as len(x) || x. A reimplementer who applies the len(x) || x convention from §5.4 info fields to the AAD label — prepending a 2-byte length (0x00 0x08) before "lo-dm-v1" — produces a different 10-byte prefix and silently broken AEAD on every message. The confirmed encoding: aad = b"lo-dm-v1" || sender_fp || recipient_fp || header_bytes — total prefix is 8 raw bytes.
Sender/recipient orientation: sender_fingerprint_raw is the fingerprint of the party calling Encrypt (local party); recipient_fingerprint_raw is the fingerprint of the remote party. On the decrypt side, these roles are reversed — the decryptor reconstructs AAD using the remote party's fingerprint as sender_fingerprint_raw and its own fingerprint as recipient_fingerprint_raw. Both fingerprints are stored in RatchetState as local_fp and remote_fp at init time; encrypt uses (local_fp, remote_fp), decrypt uses (remote_fp, local_fp) to reconstruct the correct AAD order.
Note: for the first message, recipient_fingerprint_raw (Bob's IK fingerprint) appears twice in aad: once as the standalone prefix field, and again inside encode_session_init(session_init) as si.recipient_ik_fingerprint. Both occurrences are intentional — the prefix provides fast lookup without parsing the encoded blob, and the embedded copy ties the fingerprint directly into the signed SessionInit. Bob does not need an explicit equality check between the two occurrences — AEAD authentication enforces consistency transitively: if an attacker substitutes a different value in either location, the AAD bytes change and the AEAD tag fails. A reimplementer who adds an explicit prefix_fp == session_init_fp check before AEAD is not wrong (it detects a specific tampering pattern), but it is redundant — the AEAD check subsumes it and adding a distinct error for the mismatch would create an error-type oracle.
7.4 Deterministic Header Encoding
AAD must be computed identically by sender and recipient. JSON is not suitable (field ordering, whitespace, encoding ambiguity). Headers are encoded as length-prefixed binary.
Length-prefix rule: Identity fingerprints (32 bytes) and public keys (1216 bytes) are written bare — their sizes are fixed by definition and cannot change across crypto versions, so the decoder always knows the exact size from the crypto_version context and needs no length prefix to parse them unambiguously. KEM ciphertexts (1120 bytes in lo-crypto-v1) are length-prefixed despite being fixed-size in the current version — forward compatibility requires the decoder to handle variable-size ciphertexts from future crypto versions (a lo-crypto-v2 could adopt a different KEM with a different ciphertext size). The crypto_version string is length-prefixed because it is genuinely variable-length. A reimplementer MUST NOT pattern-match "fixed-size → no prefix" — the ciphertexts are the exception because their size is algorithm-determined, not definitionally invariant within a crypto version. Exception to the "fixed-size fields bare" rule: DM queue AAD (§11.4.2) uses len(recipient_fp) || recipient_fp — a length-prefixed encoding for the recipient fingerprint despite it being a fixed 32-byte field. This is an intentional deviation from the general rule; see §11.4.2 for the design rationale. A reimplementer who applies the "bare" rule from this section to all fixed-size fields will produce wrong AAD in the DM queue context.
encode_session_init(si):
encode_session_init(si) =
len(si.crypto_version) || si.crypto_version // UTF-8, 2-byte BE len
|| si.sender_ik_fingerprint_raw // 32 bytes (fixed, no length prefix —
// fingerprints are SHA3-256 digests with
// definitionally invariant size; no future
// lo-crypto version will change them)
|| si.recipient_ik_fingerprint_raw // 32 bytes (fixed, no length prefix — same rationale)
|| si.sender_ek // 1216 bytes (fixed, no length prefix —
// sender_ek is an X-Wing public key whose
// size is definitionally fixed within lo-crypto-v1;
// identity key sizes do not change across versions)
|| len(si.ct_ik) || si.ct_ik // 1120 bytes, 2-byte BE len
// (length-prefixed despite being fixed-size in
// lo-crypto-v1: KEM ciphertext size is
// algorithm-determined, not definitionally
// invariant — a future lo-crypto-v2 could
// select a different KEM with a different
// ciphertext size; a decoder that hard-codes
// 1120 bytes would misparse future session inits)
|| len(si.ct_spk) || si.ct_spk // 1120 bytes, 2-byte BE len (same rationale as ct_ik)
|| big_endian_32(si.spk_id)
|| si.has_opk (1 byte: 0x01 or 0x00)
|| if has_opk: len(si.ct_opk) || si.ct_opk
|| big_endian_32(si.opk_id)
// When has_opk = 0x00: encoding terminates immediately here.
// No ct_opk or opk_id bytes are written. The total encoded length
// with has_opk = 0x00 is exactly 3,543 bytes. A reimplementer who
// writes zero-filled placeholders (e.g., 2 bytes len + 1120 zero
// bytes + 4 zero bytes) after the 0x00 flag produces a malformed
// encoding that fails the strict trailing-bytes check on decode
// (§7.4 "Trailing bytes after the last field → InvalidData").
All callers of encode_session_init MUST use a single shared implementation: Three separate callers use encode_session_init output: (1) §5.4 Step 6 (Alice signs the encoded bytes); (2) §5.4 Step 7 (Alice uses the encoded bytes as AEAD AAD); (3) §5.5 Step 3 (Bob re-encodes the received SessionInit to verify Alice's signature). All three MUST produce byte-for-byte identical output. Any divergence between the signer (1) and the verifier (3) causes VerificationFailed; any divergence between the signer (1) and the AAD builder (2) causes AeadFailed at decrypt_first_message. A reimplementer who inlines encode_session_init at each call site and introduces any encoding difference — field ordering, padding, prefix conventions — gets a silent failure with no diagnostic pointing to the encoding divergence. The correct pattern: a single encoding function called identically from all three sites.
encode_ratchet_header(rh):
encode_ratchet_header(rh) =
rh.ratchet_pk // 1216 bytes (fixed, no length prefix —
// ratchet_pk is an X-Wing public key with
// a definitionally fixed 1216-byte size
// within lo-crypto-v1; fixed-size fields
// are written bare per the Length-prefix
// rule above)
|| rh.has_kem_ct (1 byte: 0x01 or 0x00)
|| if has_kem_ct: len(rh.kem_ct) || rh.kem_ct // 1120 bytes total: X25519_eph_pk (32) || ML-KEM-768_ct (1088), LO X25519-first encoding (§8.1); 2-byte BE len
// (length-prefixed despite being fixed-size —
// KEM ciphertext size is algorithm-determined,
// not definitionally invariant; a future
// lo-crypto-v2 could select a different KEM
// with a different ciphertext size; the
// decoder must not hard-code 1120 bytes)
|| big_endian_32(rh.n) // always present — not conditional on has_kem_ct
|| big_endian_32(rh.pn) // always present — not conditional on has_kem_ct
Signing context: When encode_session_init output is used as the signed message (§5.4 Step 6), the label "lo-kex-init-sig-v1" (18 raw bytes, no length prefix) is prepended: HybridSign(sk, "lo-kex-init-sig-v1" || encode_session_init(si)). When the same output is used as the AAD component (§7.3), no prefix is added — the encoded bytes are embedded directly in the AAD alongside fingerprints and the DM label. A reimplementer reading this section as the encoding reference for what gets signed must include the label prefix; omitting it produces a valid encoding but an invalid signature.
All len() values are 2-byte big-endian. Fixed-size fields (fingerprints at 32 bytes, keys at 1216 bytes) omit length prefixes. Variable-length fields (crypto_version, ciphertexts) use 2-byte BE length prefixes (ciphertexts are fixed-size in lo-crypto-v1 but length-prefixed for forward compatibility across crypto versions — a future crypto_version may select a different underlying KEM with a different ciphertext size, so a decoder that assumes a fixed ciphertext length would misparse future-version session inits). This encoding is unambiguous, deterministic, and trivial to implement.
Decode validation: On decode, each ciphertext length prefix MUST equal XWING_CIPHERTEXT_SIZE (1120 bytes); any other value → InvalidData (not InvalidLength — this is a wire-format field violation, not a caller-supplied parameter mismatch; see §12 error semantics). A decoder that trusts the u16 prefix without validation would accept malformed blobs with truncated or oversized ciphertexts, leading to incorrect decapsulation inputs. The crypto_version field is validated as "lo-crypto-v1" (exact match); other values → UnsupportedCryptoVersion.
Encode error behavior for wrong-size kem_ct: On the encode path, encode_ratchet_header handles a kem_ct with the wrong length as follows: if ct.len() > 65535 (does not fit in a u16), the function returns Internal because the length prefix field cannot represent the value. If ct.len() <= 65535 but is not 1120 bytes, the function silently encodes the actual length — the 2-byte length prefix receives the actual (non-1120) length and all bytes are written to the buffer without any error. This is not a size-validation step; the encoder's only hard constraint is that the length fits in a u16. The wrong-size ciphertext is caught on the decode path: the decoder validates that the length prefix equals XWING_CIPHERTEXT_SIZE (1120 bytes) and returns InvalidData for any other value (per the "Decode validation" paragraph above). A reimplementer who expects the encoder to return Internal for any wrong-size ciphertext (not just the > 65535 case) will incorrectly assume that encode-side validation is a substitute for CSPRNG-correct X-Wing usage. The correct invariant: the encode path is not responsible for validating ciphertext sizes; the decode path is.
sender_ek is bare while ciphertexts are length-prefixed — encoding boundary hazard: In encode_session_init, sender_ek (1216 bytes) is written with no length prefix, but the immediately following ct_ik carries a 2-byte BE length prefix. A reimplementer reading the format as "keys have prefixes, ciphertexts have prefixes" and adding a 2-byte prefix to sender_ek shifts every subsequent field by 2 bytes: the byte at offset 1216 is parsed as the high byte of len(sender_ek) rather than the start of len(ct_ik), desynchronizing all subsequent fields with no error until the final byte-length check (if any). No prefix is added to sender_ek because its size is definitionally invariant (X-Wing public keys are always 1216 bytes); the length prefix on ct_ik (and all three ciphertexts) exists specifically because KEM ciphertext sizes are algorithm-determined and may change across future crypto_version values. The asymmetry is intentional — see the Length-prefix rule above.
Total encoded sizes for encode_session_init: Without OPK (has_opk = 0x00): 3,543 bytes total (2 + 12 + 32 + 32 + 1216 + 2 + 1120 + 2 + 1120 + 4 + 1 = 3,543). With OPK (has_opk = 0x01): 4,669 bytes total (3,543 + 2 + 1120 + 4 = 4,669). Decoders can use these totals as a quick-reject check before field-by-field parsing: any input not equal to 3,543 or 4,669 bytes MUST be rejected as InvalidData without further parsing. A decoder that accepts inputs of any size and relies solely on field-by-field parsing would accept truncated inputs that parse successfully up to a short point (e.g., a blob of 14 bytes matches the len(crypto_version) || crypto_version prefix), masking truncation bugs in test environments.
Progressive parsing note: The has_opk flag is at offset 3542 — the last byte of the fixed-size prefix. A streaming/progressive parser cannot determine the total session_init_bytes length until it has consumed all 3543 bytes. The usual trick of reading a length prefix from the first few bytes does not work here; the format is self-delimiting only after the fixed prefix is fully consumed. For encode_ratchet_header, the has_kem_ct flag is at offset 1216 (immediately after ratchet_pk, which occupies bytes 0-1215), similarly requiring the full fixed prefix.
Boolean marker byte strictness: The has_opk and has_kem_ct fields accept only 0x00 (absent) or 0x01 (present). Any other value → InvalidData. Decoders MUST NOT treat arbitrary non-zero values as "present" — doing so accepts malformed blobs and creates format malleability (multiple byte values encode the same logical state). Trailing bytes after the last field → InvalidData (strict parsing, same rationale as §6.8 guard 11).
Fixed-width integer re-encoding MUST be lossless: All fixed-width integer fields — spk_id, opk_id, n, pn (u32, 4 bytes each) — MUST re-encode at their full fixed width as big-endian, regardless of value. A field containing 0 MUST produce four zero bytes (0x00 0x00 0x00 0x00), not an empty field or a variable-length encoding. A Python reimplementer who parses spk_id = 0 into a Python int and re-encodes with a variable-length BE encoder (which might produce b'' for zero) produces different bytes from the original encoding, yielding a different AAD and permanent AeadFailed with no diagnostic. The "byte-for-byte identical" guarantee in §7.3 includes fixed-width integers, not only variable-length fields.
Truncated input: If the input is too short to contain all required fields, the decoder returns InvalidData (not InvalidLength). This includes the case where a length prefix claims more bytes than remain in the buffer — the decoder must not read past the end. Using InvalidLength would leak parser state — an attacker could probe incrementally longer inputs and observe the error transition from InvalidLength to InvalidData, revealing the byte offset where parsing progressed past the size check.
8. X-Wing KEM Details
8.1 Encoding (LO-specific)
LO uses X25519-first encoding (diverges from draft-09 which uses ML-KEM-first):
// LO encoding (X25519-first):
X-Wing public key (1216 B): X25519_pk (32) || ML-KEM-768_pk (1184)
X-Wing secret key (2432 B): X25519_sk (32) || ML-KEM-768_sk (2400)
X-Wing ciphertext (1120 B): X25519_eph_pk (32) || ML-KEM-768_ct (1088)
// draft-09 encoding (ML-KEM-first) — for contrast only; LO does NOT use this:
// public key: ML-KEM-768_pk (1184) || X25519_pk (32)
// secret key: ML-KEM-768_sk (2400) || X25519_sk (32)
// ciphertext: ML-KEM-768_ct (1088) || X25519_eph_pk (32)
Interoperability consequence: A reimplementer who uses draft-09's ML-KEM-first layout instead of LO's X25519-first layout produces public keys, ciphertexts, and secret keys whose byte order is inverted. Encapsulation "succeeds" (no length error), but the combiner receives reversed sub-components: pk_X from the ML-KEM portion and pk_M from the X25519 portion, producing a wrong shared secret. The mismatch surfaces only as AeadFailed at the AEAD layer with no indication of which byte offset was misinterpreted. If interoperating with a draft-09-compatible library, both parties must explicitly reorder the concatenation — LO's test suite includes a KAT that reorders a draft-09 vector into LO's X25519-first layout before decapsulation.
This encoding difference is internal only — no external interop with draft-09 implementations is required. Combiner inputs are extracted correctly regardless of encoding order; the cryptographic output is identical. Canonical byte representation: The X25519 component of an X-Wing public key is the raw 32-byte little-endian u-coordinate with no bit masking applied to the public key bytes. Only the private scalar is clamped (RFC 7748 §5), and clamping is applied at use time (inside the X25519 scalar-multiplication operation — §8.2) — the stored scalar bytes are unclamped raw random bytes. Clamping is NOT applied at storage time; the secret key bytes in the X-Wing 2432-byte blob are stored without clamping. A reimplementer who pre-clamps the scalar at storage time and then clamps again at use will compute the correct result (clamping is idempotent via RFC 7748's bit mask: bits 0-2 of byte 0 are already 0 after the first clamp; bit 7 of byte 31 is already 0; bit 6 of byte 31 is already 1), but the stored bytes will differ from soliton's unclamped format, causing silent key import failures when round-tripping through the 2432-byte serialization. Some X25519 libraries clear bit 255 of the public key byte 31 — using such a library produces a different 32-byte public key than soliton's, causing silent SPK signature verification failure (the signed bytes differ from the stored/verified bytes). Reimplementers MUST verify their X25519 library does not mask public key bits.
ML-KEM-768 public key coefficient reduction — happens inside Encaps, not at from_bytes: EncapsulationKey::from_bytes is a size check only — it does not normalize coefficients. Coefficient reduction (FIPS 203 §7.2 ByteDecode_12, which silently reduces any coefficient ≥ 3329 modulo q) occurs inside ML-KEM-768.Encaps() when the encapsulation key bytes are imported for use. The practical implication: a round-trip byte-comparison test (from_bytes then to_bytes, compare to original) will NOT detect normalization incompatibilities — the stored bytes are returned verbatim because from_bytes is a pure size check. Only a shared-secret KAT (encapsulate, decapsulate, compare shared secrets) detects normalization divergence. A foreign library that stores unreduced coefficients produces encapsulation keys that produce a different shared secret after Encaps — this surfaces as AeadFailed at the AEAD layer with no indication the key was modified. Reimplementers importing ML-KEM-768 encapsulation keys from external libraries MUST verify via KAT, not via byte-comparison. The cross-check from §8.5 also applies: re-derive the public key from the decapsulation key and compare ek_PKE bytes — a mismatch indicates encoding-domain divergence (NTT vs. coefficient-domain). This normalization divergence also affects SPK signature verification: the IK signature over the SPK (produced by HybridSign in §5.3, §10.2) covers the raw 1,216-byte SPK public key bytes as stored. If a reimplementer's ML-KEM library normalizes coefficients on import (modifying the byte representation), the bytes the reimplementer would sign or verify over differ from the raw bytes stored and transmitted by the reference implementation. The SPK signature verification step (HybridVerify at §5.5 Step 3 / §5.3) would then return VerificationFailed even when the SPK is cryptographically valid — because the signed bytes and the verified bytes are different normalized representations of the same underlying key. The normalization divergence therefore causes bundle authentication failure even when no tampering occurred.
8.2 Combiner (draft-09 §5.3)
Version pinning: lo-crypto-v1 is pinned to draft-connolly-cfrg-xwing-kem-09. Any future revision that alters the combiner construction — including a published RFC that differs from draft-09 — requires a new crypto_version string (i.e., "lo-crypto-v2") for compatibility. The XWingLabel bytes and ss_M ‖ ss_X ‖ ct_X ‖ pk_X argument order are draft-09-specific; a reimplementer using a different draft or the final RFC MUST verify these values match before using this spec.
function XWing.Combine(ss_M, ss_X, ct_X, pk_X):
return SHA3-256(ss_M || ss_X || ct_X || pk_X || XWingLabel)
XWingLabel = 0x5c 0x2e 0x2f 0x2f 0x5e 0x5c // ASCII: \.//^\ (6 bytes, label goes LAST)
ss_M= ML-KEM-768 shared secret (32 bytes)ss_X= X25519 DH output (32 bytes)ct_X= ephemeral X25519 public key (32 bytes) —ciphertext[0..32]in LO's X25519-first encoding (§8.1)pk_X= recipient X25519 public key (32 bytes) —public_key[0..32]in LO's encodingc_M= ML-KEM-768 ciphertext (1088 bytes) —ciphertext[32..1120]. Not in the combiner formula —c_Mis bound insidess_Mvia ML-KEM's implicit rejection (the pseudorandom SS depends on the ciphertext).pk_M= ML-KEM-768 public key (1184 bytes) —public_key[32..1216]. Not in the combiner formula —pk_Mis bound insidess_Mon both sides: on the decapsulator side,ss_Mis derived from the decapsulation key, which embedspk_Min itsek_PKEfield (§8.5); on the encapsulator side,ss_M = ML-KEM-768.Encaps(pk_M, randomness)directly consumespk_Mas an input — encapsulation is a function of the public key, sopk_Mis bound toss_Mthere as well. This follows draft-09 §5.3.- Hash: SHA3-256
- Label position: last (changed from draft-06 which had label first)
ss_M ‖ ss_Xargument order: Thess_M ‖ ss_Xorder is fixed by draft-09 §5.3 — it is not a local choice. Swapping them produces a different SHA3-256 output with no error signal.- Total SHA3-256 input length: 134 bytes (32 + 32 + 32 + 32 + 6 = 134). SHA3-256's rate is 136 bytes (one Keccak block absorbs all 134 bytes in a single call — no second block). A reimplementer who miscounts the input length (e.g., adding or omitting the
pk_Morc_Mconfusion from the "not in combiner" note above) produces a different hash output with no error at the hash primitive layer.
pk_X during decapsulation: The combiner requires pk_X (the recipient's X25519 public key), but the decapsulation key contains only sk_X. The decapsulator re-derives pk_X via X25519(sk_X, G) (scalar-basepoint multiplication) each time — no separate public key storage or input is needed. G is the X25519 base point defined in RFC 7748 §6.1: the u-coordinate value 9 encoded as a 32-byte little-endian integer (09 00 00 00 ... 00). X25519 libraries expose this as their scalarmult_base operation — use the library's base-point function rather than encoding G manually. This matches soliton's secret key layout (§8.5): only the X25519 scalar and ML-KEM expanded key are stored.
The label bytes decode as: \ (0x5c), . (0x2e), / (0x2f), / (0x2f), ^ (0x5e), \ (0x5c) = \.//^\.
SHA3-256 input must be one concatenated byte string — no separators, no length prefixes between fields: The five combiner inputs (ss_M, ss_X, ct_X, pk_X, XWingLabel) are concatenated as raw bytes with no separators, no length prefixes, and no delimiter bytes between them. The total input is exactly 134 bytes (32 + 32 + 32 + 32 + 6). Some hash APIs accept multiple buffers via repeated update() calls or a variadic array; these are equivalent to concatenation only when the underlying hash is a sponge/Merkle-Damgård construct that processes data chunk-boundary-invariantly. However, certain framework wrappers or tree-hash APIs (Merkle-tree SHA3, protocol-framing helpers, "typed" hash APIs) insert domain separation bytes or length prefixes between update() calls. Using such an API produces a different SHA3-256 output even when the individual fields are correct. The correct API call is either: (a) concatenate all five fields into a 134-byte buffer and call SHA3-256 once, or (b) use a streaming SHA3-256 context with raw update() — no other arguments, wrappers, or domain separation. Verification: compute SHA3-256(ss_M || ss_X || ct_X || pk_X || label) using known test inputs from Appendix F.3 and compare against the expected output.
Compile-time assertion verifies all six label bytes.
XWing.Combine MUST be internal-only — not a public API: The combiner takes raw ss_M and ss_X values as inputs and returns a SHA3-256 hash — it performs no key validation, no randomness checks, and no binding to a specific encapsulation operation. Exposing it as a callable public function allows a caller to supply arbitrary ss_X values (e.g., all-zero, repeated from a prior session, or attacker-controlled), which breaks the IND-CCA2 security guarantee of the combined scheme. The X-Wing security proof assumes ss_X is the genuine DH output from encapsulation or decapsulation — not a caller-supplied value. Implementations MUST call the combiner exclusively from within XWing.Encapsulate and XWing.Decapsulate, where ss_X is derived from a fresh ephemeral scalar (encapsulation) or from the peer's ephemeral public key and the stored secret key (decapsulation). The CAPI does NOT expose soliton_xwing_combine; the Rust API exposes XWing.Combine as pub(crate) only. Binding authors MUST NOT promote this to a public function.
The SharedSecret returned by XWing.Encapsulate / XWing.Decapsulate MUST be consumed immediately by KDF_Root and zeroized: The 32-byte shared secret (ss) is the output of XWing.Combine — it is secret key material of the same sensitivity as the inputs to KDF_Root. A binding author who returns the raw ss to callers for "custom KDF use" achieves the same security risk as exposing XWing.Combine directly: the caller can supply the ss to arbitrary downstream operations, bypassing the key hierarchy defined in §5.4 / §6.4. In Rust, xwing::SharedSecret is not Clone or Copy — it cannot be extracted without a deliberate as_bytes() call, and soliton never calls as_bytes() on the combined output outside KDF_Root. Binding authors MUST ensure the ss is passed directly into KDF_Root at the call site and zeroized before the encapsulation/decapsulation function returns. Do not buffer, log, or expose it through any intermediate field.
End-to-end encapsulation and decapsulation pseudocode:
function XWing.Encapsulate(pk):
// pk layout (§8.1): pk_X(32) || pk_M(1184)
pk_X = pk[0..32]
pk_M = pk[32..1216]
// X25519 half: generate ephemeral scalar, compute shared secret and ephemeral pk
eph_sk = random_bytes(32) // 32 raw CSPRNG bytes; do NOT pre-clamp — clamping is applied
// internally by the X25519 call per §8.5 (stored raw).
// `random_bytes(32)` is used (not `random_scalar()`) to
// avoid library functions that return pre-clamped scalars;
// storing a pre-clamped scalar violates the raw-bytes
// storage requirement (§8.5).
ct_X = X25519(eph_sk, G) // ephemeral public key (32 bytes); G = RFC 7748 §6.1 base point (u-coordinate 9)
ss_X = X25519(eph_sk, pk_X) // DH output (32 bytes); if the library rejects
// all-zero output (low-order pk_X), substitute
// [0u8; 32] — same rule as Decapsulate (§8.3)
// ML-KEM-768 half
// FIPS 203 §7.2 draws m ← B^32 (32 random bytes) internally before encapsulation.
// Deterministic-API callers (e.g., ml-kem crate's `encapsulate_deterministic`) must
// supply this m explicitly via `random_b32()`. Passing a fixed, zero, or reused m
// produces structurally valid ciphertexts but silently breaks IND-CCA2 — an attacker
// who can predict m can recover the shared secret. Each Encaps call MUST use a fresh
// 32-byte CSPRNG value; reuse across calls is not detectable at the API level.
(ct_M, ss_M) = ML-KEM-768.Encaps(pk_M) // ct_M = 1088 bytes, ss_M = 32 bytes
// Assemble ciphertext: X25519-first (§8.1)
ct = ct_X || ct_M // 32 + 1088 = 1120 bytes
// Combine
ss = XWing.Combine(ss_M, ss_X, ct_X, pk_X) // 32 bytes
zeroize(eph_sk, ss_X, ss_M)
return (ct, ss)
function XWing.Decapsulate(sk, ct):
// sk layout (§8.1, §8.5): sk_X(32) || dk_M(2400)
sk_X = sk[0..32]
dk_M = sk[32..2432]
// ct layout (§8.1): ct_X(32) || ct_M(1088)
ct_X = ct[0..32]
ct_M = ct[32..1120]
// X25519 half: re-derive pk_X from sk_X (no stored copy needed)
pk_X = X25519(sk_X, G) // scalar-basepoint multiplication
ss_X = X25519(sk_X, ct_X) // DH output (32 bytes)
// ML-KEM-768 half (implicit rejection: always returns a shared secret, never fails)
ss_M = ML-KEM-768.Decaps(dk_M, ct_M) // 32 bytes
// Combine — uses re-derived pk_X, not any value from the ciphertext
ss = XWing.Combine(ss_M, ss_X, ct_X, pk_X) // 32 bytes
zeroize(ss_X, ss_M)
return ss
pk_X re-derivation in decapsulation: The combiner requires pk_X (the decapsulator's own X25519 public key), but soliton stores only the X25519 scalar sk_X in the secret key (§8.5 — no separate public key is stored). The decapsulator computes pk_X = X25519(sk_X, G) (scalar-basepoint multiplication) on every decapsulation call. A reimplementer who passes ct_X (the encapsulator's ephemeral key from the ciphertext) as pk_X to the combiner produces a wrong shared secret — ct_X is the ephemeral encapsulator key, not the decapsulator public key. This is the most common error in X-Wing implementations.
Clamping requirement for pk_X re-derivation in non-auto-clamping libraries: The re-derivation pk_X = X25519(sk_X, G) requires RFC 7748 clamping applied to sk_X before the scalar multiply. Libraries that apply clamping automatically at every X25519 call (such as x25519-dalek) handle this transparently — the raw stored scalar is passed in and clamped internally. Libraries that require explicit pre-clamping (raw Montgomery ladders, some low-level crypto primitives) MUST have clamping applied before each X25519 call per §8.5's "Portability note." A non-clamping library computing X25519(raw_sk_X, G) without clamping produces a different public key for scalars where the low 3 bits or bits 254/255 differ from their clamped values — specifically, scalars where any of bits 0, 1, 2, 255 are set or bit 254 is clear. The mismatch between the re-derived pk_X and the actual public key causes the combiner to produce a wrong shared secret, and the AEAD fails silently. To verify clamping correctness: X25519(sk_X, G) with your library MUST equal public_key[0..32] (the stored X25519 public key). See §8.5 for the full portability note.
8.3 Low-Order X25519 Points
X25519 DH with a low-order public key produces an all-zeros output. LO uses the all-zeros value rather than rejecting the point. The all-zero check MUST be constant-time: the DH output is secret material before the check executes, so a variable-time comparison leaks one bit of information about the relationship between the ephemeral private key and the recipient's public key. The reference uses subtle::ConstantTimeEq against [0u8; 32]; see also the Constant-Time Requirements table in Appendix E. The SHA3-256 combiner absorbs this result alongside the ML-KEM shared secret and label; the full combiner output is secure regardless. Rejecting low-order points would allow an attacker with a malicious pre-key bundle to force session initiation to fail without providing any security benefit.
Why the all-zero check is sufficient for all low-order points: Curve25519 has 8 points of order dividing 8 (the cofactor), corresponding to points of order 1, 2, 4, or 8. RFC 7748 §5 clamps the scalar by clearing its three low bits (making it a multiple of 8). Multiplying any of these 8 low-order torsion points by a multiple-of-8 scalar produces the group identity. On Curve25519 in Montgomery form, the identity element has u-coordinate 0 — represented as the all-zero 32-byte string. Therefore, for any of the 8 low-order input points, the clamped scalar multiplication produces [0u8; 32]. The all-zero check is both necessary and sufficient: any low-order public key → all-zero output, and (with overwhelming probability) all-zero output → the input was a low-order point. A reimplementer who checks for a specific set of known low-order points by value (e.g., maintaining a hardcoded list of the 8 torsion points) is over-engineering — the all-zero output test is the complete check, since clamping makes it impossible for any non-torsion point to produce an all-zero DH output.
Implementation mechanism — error-catch-and-replace, not pre-filtering: The correct implementation calls the X25519 DH function normally and does NOT pre-filter low-order input points before calling DH. If the DH function returns an error or all-zero output, the caller substitutes [0u8; 32] explicitly. In soliton's Rust implementation, x25519::dh() rejects all-zero output by returning Err(DecapsulationFailed); the X-Wing encapsulate/decapsulate layer catches that error and substitutes [0u8; 32] via .unwrap_or([0u8; 32]). The underlying x25519_dalek crate's PublicKey type itself does not reject low-order points — soliton's wrapper adds the check. Other X25519 libraries behave differently on low-order input: (1) some return an error (catch and substitute [0u8; 32]); (2) some panic (catch the panic and substitute [0u8; 32]); (3) some silently return [0u8; 32] without any error signal — in this case no substitution is needed, the all-zero value is already correct, and adding an explicit error-catch that rejects the silent-success path is wrong. A reimplementer who checks for an error return and, finding none, then checks for all-zero output and substitutes [0u8; 32] handles all three behaviors correctly — the substitution of [0u8; 32] for [0u8; 32] is a no-op. The substitution is always the X-Wing layer's responsibility, not the X25519 primitive's.
Scope: This no-rejection policy applies exclusively inside X-Wing encapsulate/decapsulate (§8.2). LO-Auth (§4) uses full X-Wing KEM (not standalone X25519 DH), so the no-rejection policy applies there as well — the ML-KEM-768 component provides security even if X25519 produces an all-zero output. The standalone x25519::dh() function (used only internally within X-Wing, not exposed as a protocol-level primitive) DOES reject all-zero output — it returns an error — and the X-Wing layer catches that error and substitutes [0u8; 32] explicitly. A reimplementer who adds a zero-output rejection inside X-Wing's internal X25519 step and propagates the error rather than substituting zeros breaks interop with degenerate-but-secure key exchanges.
8.4 ML-KEM Implicit Rejection
ML-KEM-768 decapsulation implements FIPS 203 implicit rejection: invalid ciphertexts produce a pseudorandom shared secret rather than an error. Authentication failure surfaces only at the AEAD layer (wrong shared secret → wrong session/epoch key → AEAD tag mismatch). Implementations must not add explicit ciphertext validation that could create a timing oracle distinguishing valid from invalid ML-KEM ciphertexts.
X25519 does not independently provide implicit rejection — it produces a valid curve point (shared secret) for any 32-byte input, including malformed or attacker-chosen public keys. The combined X-Wing shared secret is pseudorandom on invalid ciphertext because the ML-KEM component's randomized rejection (FIPS 203 §7.3) dominates the combiner output via SHA3-256. A reimplementer constructing a non-standard X-Wing variant who removes or replaces the ML-KEM component would silently break this property — the X25519-only combiner output would be attacker-influenced rather than pseudorandom.
8.5 Secret Key Storage
LO stores the fully expanded X-Wing secret key (2432 bytes) rather than the 32-byte seed format specified in draft-09. This avoids re-running SHAKE256 key expansion on every decapsulate. This applies to all X-Wing secret keys in soliton — identity IK, signed pre-keys (SPK), one-time pre-keys (OPK), and ratchet keys are all stored in expanded 2432-byte form. The 32-byte seed form is never used for storage regardless of key type. The extra 2400 bytes per key is negligible given the 2496-byte composite key.
X25519 scalar sk_X is stored as raw bytes — clamping is applied at use time: The 32-byte sk_X field in the stored secret key is the raw random scalar from key generation (or from SHAKE256 seed expansion), before RFC 7748 clamping. Clamping (bit 255 clear, bit 254 set, three low bits of byte 0 clear) is applied at the time of each X25519 operation (X25519(sk_X, G) and X25519(sk_X, peer_pk)) by the underlying library. The stored bytes are NOT pre-clamped. A reimplementer who clamps sk_X before writing it into the secret key blob produces a different wire format: the stored bytes differ from the reference implementation, the X25519 operations that re-clamp at use time produce the same curve result (clamping is idempotent for DH), but the blob round-trip fails because the stored bytes do not match what the reference expects.
Portability note — libraries that require explicit pre-clamping: Some X25519 libraries do not apply clamping internally and require the caller to pre-clamp the scalar before each use (e.g., a raw Montgomery ladder that takes the scalar as-is). When integrating such a library: (1) do NOT clamp before storage — store the raw random bytes as specified; (2) DO clamp before each X25519 call — read the 32 stored bytes, apply RFC 7748 clamping in a temporary variable, pass the clamped bytes to the library, and zeroize the temporary immediately after. This "clamp at use time" pattern matches the reference implementation's semantics even when the library doesn't clamp automatically. Libraries that clamp automatically (including x25519-dalek used by the reference) make this transparent — the stored raw scalar is passed directly and clamped internally. A reimplementer who passes the raw stored scalar to a non-clamping library produces the wrong DH output (the unclamped scalar may have the low bits set, changing the scalar value and thus the DH result), causing silent AeadFailed at the AEAD layer. To verify: compute X25519(sk_X, G) using the library and compare against public_key[0..32] — a mismatch indicates clamping divergence.
Production keygen draws three independent OS CSPRNG values, not a SHAKE256-expanded seed: XWing.KeyGen() in production draws sk_X (32 bytes), d (32 bytes), and z (32 bytes) as three separate independent OS CSPRNG values — it does NOT draw a single 32-byte seed and expand it via SHAKE256(seed, 96). The three values are passed directly: sk_X to X25519 for the X25519 component, and d + z to ML-KEM.KeyGen_internal(d, z) for the ML-KEM component. No seed is stored or exposed. Both production key generation paths are conformant: drawing three independent OS CSPRNG values (reference path) and SHAKE256 seed expansion (alternative path) both produce interoperable key pairs. The two paths differ only in how the three random inputs (sk_X, d, z) are generated — the downstream X25519 and ML-KEM operations are identical. The reference implementation uses the three-draw path; a reimplementer who uses SHAKE256 seed expansion for production keygen produces keys that interoperate fully with the reference. The deviation SHOULD be documented, as it changes the security analysis: with seed expansion, the three components are no longer independent random values — their joint distribution is determined by SHAKE256 applied to a single seed. The SHAKE256 seed expansion path is the natural choice for test vectors and KAT reproduction (where deterministic derivation from a known seed is required), but it is also a valid production path. A reimplementer MUST NOT use a non-CSPRNG seed (e.g., a counter or a fixed value) for production keygen on either path — the seed or the three independent draws must come from the OS CSPRNG.
Seed-to-expanded-key derivation: The X-Wing 32-byte seed produces the expanded key via SHAKE256(seed, 96) → 96 bytes, split as d(32) || z(32) || sk_X(32) (draft-09 §3.2). d and z are the ML-KEM-768 seeds passed to ML-KEM.KeyGen_internal(d, z) (FIPS 203 §7.3), which produces the 2400-byte expanded decapsulation key. These are two separate arguments — ML-KEM.KeyGen_internal is NOT called with a single 64-byte d‖z concatenation. Passing a concatenation or reversing the argument order as (z, d) produces different key material with no error. sk_X is the X25519 scalar. LO's storage order is sk_X(32) || dk_M(2400) — X25519 scalar first, then the ML-KEM-768 expanded key. This is the reverse of draft-09's wire order (which places ML-KEM first). A reimplementer deriving from the seed MUST use this expansion and storage order; using draft-09's wire order produces a valid-looking 2432-byte key that silently fails at decapsulation (the X25519 and ML-KEM components are swapped, producing wrong shared secrets in both sub-KEMs).
ML-KEM-768 expanded key format (2400 bytes): The 2400-byte ML-KEM secret key is the ml-kem Rust crate's DecapsulationKey serialization, laid out as:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1152 | dk_PKE |
NTT-domain decryption key (12 polynomials × 256 coefficients × 12 bits/coeff / 8) |
| 1152 | 1184 | ek_PKE |
Encapsulation key (coefficient-domain encoding, identical to public key bytes) |
| 2336 | 32 | H(ek_PKE) |
SHA3-256 hash of the encapsulation key |
| 2368 | 32 | z |
Implicit-rejection seed (random, used for FO decapsulation) |
This is not the FIPS 203 32-byte seed form (d || z), nor the FIPS 203 standardized 2400-byte dk_PKE || ek_PKE || H(ek_PKE) || z expansion (which uses coefficient-domain for dk_PKE, not NTT-domain). The field sizes and order match FIPS 203 §7.3 ML-KEM.KeyGen_internal, but the dk_PKE encoding differs: FIPS 203 specifies coefficient-domain via ByteEncode_12, while the ml-kem crate serializes in NTT-domain. Byte-for-byte comparison with FIPS 203 output is invalid for the first 1152 bytes (dk_PKE only). The remaining three fields — ek_PKE (bytes 1152-2335), H(ek_PKE) (bytes 2336-2367), and z (bytes 2368-2399) — use standard FIPS 203 encoding and are byte-for-byte identical to FIPS 203 output. Only dk_PKE diverges; a reimplementer who mistrusts all four fields and attempts to convert or reorder all of them produces a wrong key. Other ML-KEM libraries (liboqs, PQClean, BouncyCastle) use different serialization formats. Reimplementers must verify their library's DecapsulationKey serialization matches soliton's byte layout, or perform format conversion at the deserialization boundary. Silent failure mode: there is no format magic in the 2400-byte key bytes — a wrong-format key is accepted by deserialization and only manifests as AEAD failures during decapsulation (the shared secret diverges silently). Cross-library key import requires an explicit check: re-derive the public key from the decapsulation key and compare to the known public key. A mismatch indicates a format incompatibility. Concrete comparison: for the ML-KEM-768 component, compare bytes 1152-2335 of the decapsulation key (the ek_PKE field, 1184 bytes) against public_key[32..1216] (the ML-KEM-768 portion of the X-Wing public key). For the X25519 component, compute pk_X = X25519(sk_X, basepoint) from the first 32 bytes of the secret key and compare against public_key[0..32]. Both comparisons must pass; a mismatch in either indicates an incompatible key format or encoding.
ML-DSA-65 secret keys are stored as the 32-byte seed (FIPS 204 §6.1 ξ), not the 4032 (FIPS 204 §7.2, ML-DSA-65 sigKeySize)-byte expanded form. The signing key is deterministically re-expanded from the seed on each sign operation via ML-DSA.KeyGen_internal(ξ) (FIPS 204 §6.1), which produces the full expanded signing key on each call. Re-expansion is fully deterministic — ML-DSA.KeyGen_internal consumes no CSPRNG input (FIPS 204 §6.1 defines it as a pure function of ξ). Libraries that expose a two-level API — a public KeyGen() that draws OS randomness alongside an internal KeyGen_internal(ξ) that does not — must call the internal variant; calling the public variant for key re-expansion would succeed structurally but produce a different expanded key on every call, making signing non-reproducible. Implementations using ML-DSA libraries that only accept the expanded form must perform this expansion explicitly — passing the 32-byte seed directly as the signing key to such a library produces wrong signatures (the seed is not the signing key). Libraries that accept a seed-form input (e.g., via a from_seed(ξ) constructor) call KeyGen_internal internally; check the library's API.
ML-DSA seed expansion for low-level library APIs: FIPS 204 §6.1 Algorithm 1 (ML-DSA.KeyGen) applies an internal expansion to ξ: (ρ, ρ', K) = SHAKE256(ξ ‖ k ‖ ℓ, 128) (where k = 5, ℓ = 4 for ML-DSA-65, expressed as single bytes), followed by polynomial sampling from ρ and ρ'. Some ML-DSA libraries expose this two-step structure with separate keygen_internal(d, z) parameters — note that these are not the same d and z as ML-KEM's seed expansion; ML-DSA uses (ρ, ρ', K) as its intermediate state, derived differently. A reimplementer whose library requires explicit seed expansion must run Algorithm 1 §6.1 in full before constructing the signing key; there is no simple fixed-length hash split analogous to ML-KEM's SHAKE256(seed, 96) → d ‖ z ‖ sk_X. In practice, FIPS 204-compliant libraries targeted at this use case provide a KeyPair::from_seed(ξ) or equivalent entry point that performs Algorithm 1 internally — verify that the library entry point accepts the 32-byte seed directly and runs Algorithm 1 §6.1, rather than accepting already-expanded polynomial state. ML-DSA-65 public keys (1952 bytes) use the standard FIPS 204 pkEncode format and are compatible with compliant implementations (liboqs, PQClean, BouncyCastle) without format conversion — unlike ML-KEM-768 (see above), there is no NTT-domain encoding divergence.
ML-DSA-65 cross-check requirement for cross-library import: Importing a 32-byte ML-DSA-65 seed from an external source produces no immediate error — from_seed(ξ) succeeds for any 32-byte input, and the re-derived public key always matches the stored public key for the same seed. However, if the seed bytes don't correspond to the intended keypair (wrong format, wrong byte order, corrupted), signatures produced with the imported seed verify against the re-derived public key but not against the original stored public key in the identity blob. The size check passes, expansion succeeds, and signatures look valid — the mismatch is only visible if the other party's stored ML-DSA public key is available for comparison. Cross-library key import requires explicit verification: call ML-DSA.KeyGen_internal(candidate_seed) (FIPS 204 §6.1), derive the public key from it, and compare against the known ML-DSA-65 public key (composite_pk[1248..3200], 1952 bytes). A mismatch indicates an incompatible seed format and MUST be treated as InvalidData — proceeding would produce signatures that the receiver cannot verify.
Ed25519 secret keys are stored as the 32-byte seed (RFC 8032 §5.1.5), not the 64-byte expanded form (SHA-512 of seed). The signing key is deterministically expanded from the seed on each sign operation. The 32-byte ed25519_sk field in the composite secret key (bytes 2432-2464, §2.2) is this seed. Interop note: Libraries that represent Ed25519 private keys as 64-byte seed || public_key (libsodium, Go crypto/ed25519, PyNaCl) must extract only the first 32 bytes as the seed. The trailing 32 bytes (public key copy) are not part of soliton's secret key layout — passing the full 64-byte representation to key extraction produces corrupted output.
9. Verification Phrases
9.1 Purpose
A short human-readable phrase derived from both parties' identity public keys. Both parties can compare the phrase out-of-band (voice call, in-person) to verify identity key authenticity. The phrase is independent of session state — it depends only on the two identity keys and remains stable across session resets.
9.2 Algorithm
function VerificationPhrase(pk_a, pk_b):
// Both pk_a, pk_b must be full LO composite identity public keys per §2.2
// (3200 bytes: X-Wing 1216 + Ed25519 32 + ML-DSA 1952). Passing only
// a sub-key component (e.g., the 1216-byte X-Wing key) returns InvalidLength;
// passing a different 3200-byte value (e.g., padded or truncated) silently
// produces a different phrase.
// pk_a == pk_b → InvalidData (self-verification produces a valid phrase
// that gives a false sense of security — the user verified against their own key).
// Step 1: Sort keys lexicographically ascending — smaller key first.
// The comparison is over the full 3200-byte raw public key bytes, NOT over
// a fingerprint, hash, or any sub-key component. The fingerprint
// (SHA3-256 of the key) is used as a canonical identifier throughout the
// rest of the protocol — a reimplementer who sorts by SHA3-256(key) instead
// of by the key itself produces a different ordering silently (the sort
// succeeds, the phrase is wrong, no error is returned).
// "Ascending" means the key that is lexicographically smaller (byte-by-byte,
// left to right, unsigned comparison) is placed first. If pk_a <= pk_b,
// first = pk_a, second = pk_b; otherwise first = pk_b, second = pk_a.
// Descending order (larger key first) produces a different hash — silently
// incompatible phrases with no runtime error.
(first, second) = sort_lexicographic_ascending(pk_a, pk_b)
// Step 2: Concatenate with domain separation label and hash.
hash = SHA3-256("lo-verification-v1" || first || second) // label = 18 bytes
// Total SHA3-256 input: 6,418 bytes (18-byte label + 3,200-byte first + 3,200-byte second).
// The full 3200-byte composite public key is hashed — NOT the 32-byte fingerprint
// (SHA3-256 of the key). Using fingerprints instead silently produces different phrases
// with no error signal and reduces the preimage size from 3200 bytes to 32 bytes.
// Step 3: Map hash bytes to word indices.
// Consume 2-byte chunks (u16, big-endian). The read cursor advances by 2 bytes
// for each sample regardless of acceptance or rejection — rejected values are
// discarded, not retried. Accept only values in [0, 62208)
// (floor(65536 / 7776) × 7776 = 8 × 7776 = 62208) to eliminate modular bias.
// Rejection rate: (65536 − 62208) / 65536 ≈ 5.1% per sample.
// On exhaustion of 32 bytes, rehash: hash = SHA3-256("lo-phrase-expand-v1" || round || hash).
// Total input: 52 bytes = 19-byte label + 1-byte round (u8) + 32-byte previous hash.
// The read cursor resets to byte 0 of the new hash — no carry-over from the previous hash.
// Concatenation order: 19-byte label, then 1-byte round (u8), then 32-byte previous hash.
// Round counter starts at 1 (first rehash uses round = 0x01, range 1..=19).
// Starting at 0 vs 1 produces different hash outputs — this is interop-critical.
// Maximum round count is 19. Reaching round 20 → Internal error (structurally
// unreachable at probability < 2^-150; implementations MUST treat as fatal and
// return Internal — this indicates CSPRNG failure or a broken hash function,
// not a recoverable condition. Do NOT retry or fall back to fewer words).
// 16 initial samples + 19 rehash rounds × 16 = 320 total samples; termination probability < 2^-150.
// "16 initial samples" means 16 candidate u16 values extracted from the 32-byte SHA3-256 output
// (32 bytes / 2 bytes per u16 = 16 candidates). Each candidate is an attempt that may be accepted
// (if < 62,208) or rejected (if ≥ 62,208). These are NOT 16 output words — 7 words are needed;
// each hash round provides at most 16 candidate slots, typically more than enough for 7 words.
words = []
while len(words) < 7:
val = next_accepted_u16(hash) // bias-free, rejection sampling
words.append(EFF_WORDLIST[val % 7776])
return " ".join(words)
Canonical output format: The returned string is the seven words joined by single ASCII space (0x20) characters, with no leading or trailing whitespace. Words are lowercase as they appear in the EFF large wordlist — the wordlist is already lowercase; no case transformation is applied. Programmatic comparison of two phrases MUST use exact byte equality on the canonical string — case-folding or whitespace normalization before comparison is incorrect and masks implementation divergence.
fingerprint_hex() produces lowercase hex — same constraint as §2.1: The fingerprint_hex() function used for display returns 64 lowercase hexadecimal characters (digits 0-9 and lowercase letters a-f). This matches the §2.1 specification ("64 lowercase hex chars"). Any implementation that produces uppercase hex fingerprints (e.g., using %X format in C or strings.ToUpper in Go) diverges from the canonical form — verification phrase comparison and fingerprint display will not match across implementations even if the underlying keys are identical.
The word index is computed as val % 7776. The rejection threshold 62,208 (= 8 × 7,776) ensures uniform distribution — values ≥ 62,208 are rejected to eliminate modular bias.
Cursor advance on rejection is mandatory for interoperability: The read cursor advances by 2 bytes for every u16 sample regardless of whether the sample is accepted or rejected. An implementation that re-reads the same 2-byte position on rejection (i.e., advances only on acceptance) produces a different phrase for any input containing a rejected sample — occurring in approximately 5.1% of samples. Since the 7-word phrase requires 7 accepted samples, and any hash containing a rejected sample causes cursor divergence, the two implementations will agree only for the ~69% of 32-byte hashes that happen to contain no rejected sample in their first 7 accepted positions. Reimplementers MUST advance the cursor unconditionally — treating rejection as "advance past the rejected sample" (discarded), not "retry the same position." The test vector F.9 exercises this behavior explicitly (see Appendix F).
The EFF large wordlist contains exactly 7776 words, 0-indexed (entry 0 = "abacus", entry 7775 = "zoom"). Each word carries log2(7776) ≈ 12.9 bits of entropy. Seven words provide ≈ 90.3 bits of entropy. A 1-indexed implementation maps every index to a different word — this is a silent interop failure. Implementations MUST verify their embedded wordlist matches SHA3-256 a1e90a00ec269fc42a5f335b244cf6badcf94b62e331fa1639b49cce488c95c5 (full reference in Appendix D). Mismatched wordlist copies — different versions, incomplete dice-prefix stripping, trailing whitespace differences — produce silently incompatible phrases with no error indicator.
Canonical byte sequence for the checksum: The EFF large wordlist source file ships with dice-prefix columns (11111\tabacus, etc.). To produce the canonical form: (1) strip the dice prefix and tab from each line, leaving only the word; (2) use LF (\n, 0x0a) line endings — no CRLF; (3) include a trailing LF after the last word. The resulting file is 7776 lowercase words, one per line with a trailing newline, totalling 43,186 bytes. The SHA3-256 of this byte sequence is a1e90a00ec269fc42a5f335b244cf6badcf94b62e331fa1639b49cce488c95c5. A wordlist with CRLF endings, no trailing newline, or retained dice prefixes produces a different hash — verify independently before embedding.
9.2.1 Error Summary
| Error | Condition |
|---|---|
InvalidLength |
Either pk_a or pk_b is not exactly 3200 bytes. The 3200-byte requirement reflects the full LO composite identity public key (X-Wing 1216 + Ed25519 32 + ML-DSA 1952 — §2.2). Passing only a sub-key component (e.g., the 1216-byte X-Wing portion) returns InvalidLength. |
InvalidData |
pk_a == pk_b (self-verification). A phrase computed from a key paired with itself gives a false sense of security — both parties see the same phrase regardless of the other's key, providing no authentication signal. Public keys are non-secret material — variable-time comparison (==) is used, not ct_eq. |
Internal |
Rehash round counter reached 20 — structurally unreachable at probability < 2⁻¹⁵⁰. Indicates CSPRNG failure or a broken hash function. Must NOT be retried. |
9.3 Properties
- Order-independent:
VerificationPhrase(A, B) == VerificationPhrase(B, A). - Deterministic: Given the same two identity keys, always produces the same phrase.
- Unbiased: Rejection sampling eliminates modular bias in word selection.
- Session-independent: Depends only on long-term identity keys, not session state.
- Wordlist: EFF large wordlist, 7776 words, compile-time length assertion.
9.4 Security Analysis
Second-preimage resistance: ~90.3 bits. An attacker trying to find a key that produces the same phrase when paired with a specific victim key must brute-force ~2^90 SHA3-256 hashes.
Birthday collision resistance: ~45 bits. An attacker who freely generates their own identity keys can generate ~2^45 keys; by the birthday paradox, with >50% probability two will produce the same verification phrase when paired with a given victim key. No CSPRNG access or key-generation control over the victim is required — the attacker generates their own keys until a collision is found. The attacker registers one key, establishes a legitimate verification phrase, then substitutes the colliding key — the victim's out-of-band check passes despite the key swap.
2^45 SHA3-256 operations (~35 trillion hashes) is expensive but within reach of well-resourced state-level attackers. For most threat models (where the attacker does not control key generation at scale), the ~90.3-bit second-preimage bound is the relevant security parameter. Applications with state-level threat models should supplement verification phrases with full fingerprint comparison.
10. Key Management
10.1 Identity Key
- Generated once, stored permanently.
- MUST be encrypted at rest (passphrase, device key, or platform secure storage).
- Loss = loss of identity. No recovery by design.
- Used for: auth (X-Wing component, §4), hybrid signing of pre-keys (§3), session initiation signing (§5.4 Step 6), KEM decapsulation in LO-KEX (§5.5 Step 4), and HKDF info binding in LO-KEX (§5). IK compromise alone is insufficient to derive session keys — also requires SPK private key (§5.6).
10.2 Signed Pre-Key Rotation
- Rotate every 7 days (recommended).
- Retain the full SPK keypair (secret key and SPK ID) for 30 days after rotation (grace period for delayed session inits). Incoming
session_initblobs carry anspk_idused to look up the correct decapsulation key; storing only the key bytes without the ID makes this lookup impossible. The 30-day clock starts when the replacement SPK is uploaded and the old SPK leaves the active bundle — not when the old SPK was originally generated. An SPK that was active for 7 days and then rotated is retained for 30 additional days (37 days total from generation). - After grace period, delete old SPK private key.
10.3 One-Time Pre-Key Management
- Upload batches of 100.
- Replenish on DM_PREKEY_LOW (remaining < 10).
- Delete private key immediately after single use.
- OPKs have no time-based expiry — an unconsumed OPK private key remains valid indefinitely until consumed or explicitly deleted by application policy. Unlike SPKs (which have a 30-day retention window after rotation, §10.2), OPKs are not rotated on a schedule.
- Protocol functions without OPK (reduced initial forward secrecy only). When the OPK pool is empty, servers MUST return a bundle with
has_opk = falserather than rejecting the bundle request. Refusing to serve a bundle when no OPKs are available prevents session initiation entirely and is a denial-of-service hazard. An empty pool is a temporary operational condition, not a protocol error — the session proceeds without OPK and Alice and Bob acknowledge the reduced forward-secrecy guarantee.
10.4 Ratchet Key Lifecycle
- Generated per KEM ratchet step.
- Previous ratchet private key deleted (zeroized via
ZeroizeOnDrop) after step completes. - Previous receive epoch key retained for one epoch (late message grace period), then zeroized.
10.5 Memory Hygiene
Zeroize all sensitive material immediately after use:
- Shared secrets after key derivation.
- Message keys after encrypt/decrypt.
- Old ratchet private keys.
- Auth shared secrets.
- Intermediate KDF outputs.
- Streaming AEAD key (caller copy): After calling
stream_encrypt_initorstream_decrypt_init, the library holds an internal copy of the key in the encryptor/decryptor handle. The caller's original key buffer is NOT zeroed by the library — the caller MUST zeroize their copy immediately after init returns. See §15.1 for the key lifecycle. The handle's internal copy is zeroized automatically when the handle is freed (stream_encrypt_free/stream_decrypt_free).
Use Zeroizing<T> wrappers and ZeroizeOnDrop trait (Rust zeroize crate). [u8; N] is Copy — after Zeroizing::new(val), explicitly .zeroize() the source copy.
AEAD output buffer pre-allocation: When encrypting into a growable buffer, pre-allocate the full output capacity (plaintext.len() + 16 for Poly1305 tag) before writing any data. If the buffer reallocates mid-write, the abandoned heap region containing partial plaintext is freed without zeroization — the allocator does not zero freed memory. This applies to any language with growable buffers (Rust Vec, Go slices, Python bytearray). Pre-computing the output size eliminates reallocation entirely.
Decompression intermediate buffers: During storage decryption (§11.3) and streaming decryption (§15.5), the zstd decoder allocates internal buffers that are freed without zeroization — only the final output is wrapped in Zeroizing. The same applies to compression during encryption. These intermediate buffers may contain plaintext fragments on the heap. Implementations that require stronger guarantees should use a custom allocator that zeros on deallocation, or accept that decompression intermediates are a residual exposure window.
10.6 Passphrase-Based Key Derivation (Argon2id)
Identity keys stored on-device MUST be encrypted at rest. For passphrase protection use primitives::argon2::argon2id (RFC 9106, Argon2id variant, version 0x13 / v1.3). The version MUST be 0x13 — RFC 9106 also defines v1.0 (version 0x10), and some libraries default to it. Using the wrong version produces different output and silently incompatible key derivation.
Presets:
| Preset | m_cost | t_cost | p_cost | Use case |
|---|---|---|---|---|
OWASP_MIN |
19 MiB (19456 KiB) | 2 | 1 | Interactive auth, latency < 1 s |
RECOMMENDED |
64 MiB (65536 KiB) | 3 | 4 | Stored keypair protection, ~0.1-2 s (hardware-dependent; modern multi-core hardware typically 0.1-1 s) |
WASM_DEFAULT |
16 MiB (16384 KiB) | 3 | 1 | WASM targets (single-threaded, constrained memory) — p_cost = 1 because WASM runtimes are single-threaded: p_cost > 1 serializes lane execution without achieving any parallelism, multiplying wall-clock time with zero additional security benefit |
Requirements:
- Salt: at least 8 bytes; 16 or 32 random bytes recommended. Use
primitives::random::random_array::<16>(). - Output: caller-allocated; 32 bytes for a 256-bit symmetric key; any positive length accepted. Not length-extensible: requesting 32 bytes and requesting 64 bytes produce outputs where the first 32 bytes are completely different — Argon2id's variable-length output uses Blake2b's long-output mode, which re-hashes the entire state for different output lengths. A reimplementer who requests a larger output and slices it to 32 bytes will silently produce an incompatible key.
- Zeroize output with
zeroize::Zeroize::zeroize(&mut out)or wrap inZeroizingafter use. - Error-path zeroization: On any error return (invalid parameters, Argon2 library failure), the output buffer is explicitly zeroized by the implementation before returning. Callers are NOT required to zeroize the output on the error path — but reimplementers MUST apply the same zeroization on failure; omitting it leaves partial key material in a caller-visible buffer with no obligation or documentation to clean it up.
- The salt MUST be stored alongside the encrypted key material — it is not secret and may be stored in plaintext. Without the original salt, the Argon2id derivation cannot be reproduced and the protected key is permanently unrecoverable.
Validation bounds:
| Parameter | Min | Max |
|---|---|---|
m_cost |
max(8, 8 × p_cost) KiB (RFC 9106 §3.1 block minimum: 8 blocks minimum; each lane requires at least 8 blocks, so p_cost lanes require 8 × p_cost blocks minimum; the standalone minimum of 8 applies when p_cost = 1, making max(8, 8 × p_cost) = 8) |
4 GiB (4194304 KiB) m_cost is in KiB, not bytes: passing 65536 means 64 MiB (correct for RECOMMENDED), not 65536 bytes (which would be only 64 KiB and silently produce a different key). Argon2 accepts any m_cost ≥ the minimum with no error, so a factor-of-1024 unit mistake causes silent incompatibility. |
t_cost |
1 | 256 |
p_cost |
1 | 256 |
| output length | 1 byte (inclusive) | 4096 bytes (soliton-imposed; RFC 9106 allows up to 2³²−1 bytes) |
| salt length | 8 bytes | 268,435,456 bytes (256 MiB) — CAPI limit; RFC 9106 allows up to 2³²−1 bytes, but soliton_argon2id caps salt input at the general CAPI buffer limit of 256 MiB to prevent allocation exhaustion |
| password length | 0 bytes | 268,435,456 bytes (256 MiB) — CAPI limit. Zero-length password is accepted: an empty password (password = NULL or password_len = 0) is valid input; soliton_argon2id passes zero bytes to Argon2id without error. Callers MUST validate non-empty passwords at the application layer if their threat model requires it — the primitive does not enforce this. |
Argon2 library failure → Internal: Parameter-validation failures (violations of the bounds table above) return InvalidData. An Argon2 library-internal failure during the hash operation itself (OOM, BLAKE2 internal error — structurally unreachable on correct parameters under normal OS conditions) returns Internal. Binding authors who need to enumerate all possible return codes must include Internal (-12) for this path. On any error (including Internal), the output buffer is zeroed before returning.
The 4096-byte output cap is soliton-specific — it is not mandated by RFC 9106 (which allows outputs up to 2³²−1 bytes). The cap prevents allocation-exhaustion attacks in server contexts where output length comes from untrusted input (e.g., a malicious client requesting a 4 GiB KDF output). A reimplementer who removes the cap to "follow the standard" reintroduces this vector.
The t_cost = 256 cap bounds iteration-time exhaustion: t_cost controls the number of passes over the memory block. Each pass takes time proportional to m_cost, so an adversary supplying untrusted t_cost from a request (e.g., a client sending its KDF parameters to a server that re-derives the key) can force arbitrarily long computation. The cap of 256 limits total work to 256 × m_cost passes, bounding the server-side CPU cost to a predictable maximum regardless of client-supplied input. This is the same defense-in-depth motivation as the p_cost = 256 and output-length caps — all three parameters are capped to prevent resource exhaustion when Argon2id parameters originate from untrusted input rather than the application's own configuration.
Error types: Salt too short (< 8 bytes) or output length violations (0 bytes or > 4096 bytes) return InvalidLength. Cost parameter violations (m_cost, t_cost, p_cost out of bounds or below argon2 library minimums) return InvalidData. Coupled constraint: RFC 9106 §3.1 additionally requires m_cost >= 8 × p_cost — each parallel lane requires at least 8 KiB of memory. Combinations where both m_cost and p_cost are individually within bounds but violate this coupling (e.g., m_cost=100, p_cost=100) return InvalidData. The individual upper-bound caps (m_cost > M_COST_MAX, t_cost > T_COST_MAX, p_cost > P_COST_MAX) are checked in soliton code before the library call. The coupled m_cost >= 8 × p_cost constraint is enforced by the argon2 library's parameter constructor (Params::new) and mapped to InvalidData via .map_err — it is not a soliton-level pre-check. A reimplementer who adds their own pre-checks must include this coupled constraint explicitly; checking only the individual caps will miss it.
InvalidLength.expected for zero-length output: When output_len = 0, InvalidLength is returned with expected = 1 (the minimum valid output length), NOT expected = 4096. The expected field reflects the bound violated: for output_len < 1, it signals the minimum; for output_len > 4096, it signals the maximum. A caller who inspects the expected field programmatically to build a diagnostic message should not assume that expected = 4096 means "value too small" — the value that appears in expected is always the valid-range bound that was violated, not a fixed error code. This is consistent with InvalidLength semantics throughout soliton (see §12 error semantics).
Usage: Argon2id is a building-block primitive — it derives a symmetric key from a passphrase, but does not define an encrypted blob format. The application is responsible for using the derived key with an AEAD to encrypt identity key material, and for defining the on-disk format. soliton does not export a combined "encrypt identity with passphrase" function; the CAPI exposes soliton_argon2id as the KDF and the application composes AEAD encryption separately.
Recommended composition: For passphrase-protected identity keys, use XChaCha20-Poly1305 (the same AEAD as the rest of the protocol) with a 24-byte random nonce (random_bytes(24)) and the Argon2id-derived 32-byte key used directly as the AEAD key — no secondary KDF or key expansion step. The 32-byte Argon2id output is already a uniformly distributed key of the correct size for XChaCha20-Poly1305. A reimplementer who adds an additional HKDF step (e.g., HKDF(argon2_output, salt=nonce, info="...")) produces incompatible ciphertext with no error at encryption time — the mismatch surfaces only as AeadFailed at decryption. The on-disk format is salt (16 bytes) ‖ nonce (24 bytes) ‖ AEAD ciphertext (plaintext_len + 16 tag). Minimum parseable blob size: With the recommended format, a blob must be at least 56 bytes (16 salt + 24 nonce + 16 Poly1305 tag with empty plaintext). Decoders MUST reject blobs shorter than 56 bytes before attempting AEAD — slicing a sub-24-byte remainder for the nonce causes out-of-bounds access in C or a panic in Rust. Return InvalidLength (not AeadFailed) for blobs shorter than 56 bytes; this is a pre-AEAD framing check on publicly observable data, not an authentication failure. Note: the storage blob format (§11.1) enforces a 42-byte minimum analogously — passphrase-protected key blobs need the same pre-AEAD guard. No AAD is required (the salt and nonce are integrity-protected via their role in the derivation/encryption — changing either produces decryption failure). This composition is not normative (applications may use a different AEAD or format), but following it ensures interoperability between independent implementations of passphrase-protected key storage. An application that chooses a different AEAD (e.g., AES-256-GCM) produces encrypted keys that are incompatible with applications following this recommendation.
Parameter flexibility limitation and extended blob format: The recommended salt(16) ‖ nonce(24) ‖ ciphertext format does not encode Argon2id cost parameters. If an application later upgrades from OWASP_MIN to RECOMMENDED parameters, existing blobs become permanently undecryptable without out-of-band knowledge of which parameters were used. Applications that may need to change parameters should use the extended format: m_cost (4 bytes, BE u32) ‖ t_cost (1 byte) ‖ p_cost (1 byte) ‖ salt (16 bytes) ‖ nonce (24 bytes) ‖ ciphertext. Extended format serialization constraint: t_cost and p_cost are stored as single bytes (u8, values 0x01-0xFF = 1-255). The bounds table above allows t_cost and p_cost up to 256, but a value of 256 does not fit in a u8 (256 = 0x100 truncates to 0x00, which is invalid — the minimum is 1). Applications using the extended format MUST restrict t_cost and p_cost to 1-255. The m_cost field is stored as a 4-byte BE u32, which accommodates the full 4 GiB maximum without constraint. Important: soliton does not implement a passphrase-blob encoder or decoder. The CAPI exposes only soliton_argon2id as the KDF primitive — there is no soliton_passphrase_encrypt or soliton_passphrase_decrypt function. Both the basic and extended format layouts are APPLICATION-LAYER conventions for applications to implement. The magic-byte discriminator described below is a RECOMMENDED convention for achieving cross-implementation interoperability, not a normative value enforced by the soliton library. A decoder that needs to support both formats MUST distinguish them using a magic prefix byte (0x00 for basic, 0x01 for extended — recommended values for interoperability), NOT by a length check alone. A length check is only unambiguous when the plaintext is shorter than 6 bytes (total blob ≤ 77 bytes for basic, ≤ 83 bytes for extended): for any real-world content with ≥ 6 bytes of plaintext, the size ranges of the two formats overlap completely and a length-based discriminator silently misparses every ambiguous blob. A reimplementer who uses a length check will find it works on test vectors with empty or short plaintexts but fails in production. The magic-byte scheme is the only reliable approach for cross-implementation interoperability. The extended format minimum parseable size is 62 bytes (4 + 1 + 1 + 16 + 24 + 16 tag with empty plaintext). Magic-byte interaction with minimum blob sizes: The 56-byte and 62-byte minimums above are for the format bodies — the payload after the magic discriminator byte. When the magic-byte discriminator is prepended at offset 0, the minimum sizes inclusive of the discriminator become 57 bytes (basic: 0x00 ‖ salt ‖ nonce ‖ tag) and 63 bytes (extended: 0x01 ‖ m_cost ‖ t_cost ‖ p_cost ‖ salt ‖ nonce ‖ tag). Decoders using the magic-byte scheme MUST apply the format-inclusive minimum (57 or 63 bytes) as their pre-AEAD size check, not the format-body minimums (56 or 62). Test vector F.29 uses the basic format (parameters supplied out-of-band). No test vector covers the extended format (0x01 ‖ m_cost ‖ t_cost ‖ p_cost ‖ salt ‖ nonce ‖ ciphertext) — the encoding of m_cost as a 4-byte BE u32 and t_cost/p_cost as single bytes is verified only through the extended-format decoder in the reference implementation (tests/compute_vectors.rs). Implementors adding extended-format support should verify their encoder output matches the reference decoder by running the reference integration test.
Unicode normalization: No Unicode normalization is applied — raw UTF-8 bytes are passed directly. Multi-platform applications MUST normalize passwords to NFC before calling, because iOS (NSString), Android (String), and Rust (str) use different internal representations; "café" may encode differently across platforms, producing different keys from the same apparent passphrase.
Invalid UTF-8 passthrough: Argon2id (RFC 9106) accepts arbitrary byte strings — it is not a Unicode function. The soliton argon2id primitive does NOT validate that the password bytes are well-formed UTF-8. Invalid UTF-8 byte sequences (e.g., 0xFF, lone continuation bytes) are passed through to Argon2id unchanged and produce a deterministic key. A reimplementer who adds a UTF-8 validation step at their API boundary (rejecting invalid UTF-8 with InvalidData) produces a stricter interface than the reference — callers passing arbitrary byte strings (not just UTF-8 passwords) to the CAPI will receive InvalidData from the reimplementation but succeed with the reference. The CAPI soliton_argon2id accepts *const u8, usize and passes the bytes through without UTF-8 checking.
Notes:
- Only the Argon2id variant is supported (hybrid of Argon2i and Argon2d; recommended by RFC 9106 §4 for on-disk key material).
- Not used internally by the protocol KDFs (HKDF-SHA3-256); provided for application-layer key protection.
- The
argon2crate zeroizes internal memory blocks on drop. ad(associated data) is always empty: soliton passes an empty byte string as the Argon2idadparameter unconditionally. RFC 9106 definesadas an optional context distinguisher (analogous to HKDFinfo), but the soliton KDF does not use it. Reimplementers MUST pass emptyad— a non-emptyadproduces a different derived key with no error signal. Per-language: C callers usingargon2_ctxdirectly MUST set.ad = NULL, .adlen = 0; Python'sargon2-cffihas noadparameter (always empty, correct by construction); Go'sargon2.IDKeyhas noadparameter (always empty, correct by construction); Rust'sargon2crate usesParams::default()which setsadto empty.secret(pepper) is always empty: soliton passes an empty byte string as the Argon2idsecretparameter. Thesecretinput provides a server-side pepper, but soliton does not use it (the pepper would require secure server-side key management outside the protocol scope). Reimplementers MUST pass emptysecret— a non-emptysecretproduces a different derived key with no error signal. Per-language: C callers usingargon2_ctxdirectly MUST set.secret = NULL, .secretlen = 0; Python'sargon2-cffihas nosecretparameter (always empty, correct by construction); Go'sargon2.IDKeyhas nosecretparameter (always empty, correct by construction); Rust'sargon2crate usesParams::default()which setssecretto empty. See Appendix B for the full parameter table.
11. Server-Side Encryption at Rest
11.1 Storage Blob Format
Messages are batched, compressed, then encrypted. Blob format:
[version: 1 byte, offset 0] [flags: 1 byte, offset 1] [nonce: 24 bytes, offsets 2-25] [ciphertext + tag, offset 26+]
Version byte (1-255): Storage encryption key version. Value 0 is reserved and rejected.
Version 0 on the decrypt path: Version 0 is rejected at key creation (StorageKey::new) and never enters the keyring. A blob header with version 0 therefore produces a keyring lookup miss, returning AeadFailed — not an early InvalidData. Implementations MUST NOT add a pre-AEAD version-0 check on the decrypt path; doing so returns a different error variant and creates an error-type oracle. This guarantee depends entirely on the keyring construction invariant: the decrypt path itself performs no version-0 check — it relies on StorageKey::new having enforced version ≠ 0 at key creation time, so no version-0 key can ever be in the keyring to produce a lookup hit. An implementation that allows version-0 keys to be added via a debug path, test fixture, or internal bypass silently breaks this guarantee — version-0 blobs would then decrypt successfully against a version-0 key, producing correct plaintext where the spec mandates AeadFailed. Implementations MUST enforce version ≠ 0 at key construction unconditionally; the decrypt path's correctness is derived from it.
Flags byte (bitfield):
- Bit 0: compression. 0 = none, 1 = zstd.
- Bits 1-7: reserved (must be 0; blobs with reserved bits set are rejected as
AeadFailed— notUnsupportedFlags— to prevent error oracles that distinguish pre-AEAD validation failures from authentication failures).
Nonce: 24 bytes generated from the OS CSPRNG (random_bytes(24)) per encryption call. Birthday collision probability is ~2⁻⁹⁶ per pair, negligible across realistic encryption volumes per key version. For context: at one billion encryptions per day (10⁹/day), the expected time to a first nonce collision under the same key bytes exceeds 10²⁰ years — exhausting the 2⁹⁶ nonce space is not a realistic threat. Key rotation MUST be driven by key-material compromise concerns and organizational policy, not by nonce-collision probability. The 24-byte nonce space is large enough that nonce exhaustion is structurally irrelevant; rotate keys on a schedule appropriate for the sensitivity of the protected data. The birthday bound is per key_bytes, not per version number: assigning a new version number to the same key bytes does not reset the nonce pool — all blobs encrypted under any version that maps to the same key bytes share a single nonce space. Operators MUST use fresh, independently generated key bytes for each new key version. Reusing key material across version numbers provides no cryptographic isolation.
Minimum blob length: 42 bytes (1 + 1 + 24 + 16-byte Poly1305 tag with empty ciphertext).
Version and flags are AEAD-authenticated via AAD (§11.4): both fields are included verbatim in the AAD that is passed to XChaCha20-Poly1305 encryption. A reimplementer who reads §11.1 and implements the encrypt path without reaching §11.4 may omit version and flags from the AAD — the AEAD succeeds, but the resulting blob is malleable: an attacker can flip the compression bit or substitute a different version byte without detection. The AAD construction in §11.4 is not optional.
11.2 Pipeline
Write: batch → serialize → compress (zstd if enabled) → construct AAD → encrypt (XChaCha20-Poly1305) → prepend version + flags + nonce → write.
Read: fetch → parse version + flags + nonce → reject reserved flag bits as AeadFailed (not UnsupportedFlags — §11.1 oracle collapse) → look up key by version → reconstruct AAD → decrypt → decompress (if flag set) → deserialize.
Compression before encryption is mandatory ordering (encrypted data is incompressible).
11.3 Compression
encrypt_blob(compress=true) always compresses and always sets flags=0x01, regardless of whether zstd output is larger than the input. There is no expansion-ratio skip. A reimplementer who skips compression when it would expand must set flags=0x00 (not flags=0x01) — see the "Zstd level 1 expansion" note below. Empty plaintext with compress=true: when the plaintext is empty (0 bytes) and compress=true, the encoder MUST still call zstd on the empty input and store the resulting non-empty zstd frame. An empty zstd input produces a minimal valid zstd frame (~4-12 bytes, not 0 bytes — informational only; implementations MUST NOT add a minimum frame size check based on this range: adding a "frame must be ≥ 4 bytes" pre-AEAD guard would reject AEAD-authenticated blobs from future-compatible encoders that use a different zstd version or configuration, and re-create the oracle-collapse problem by returning a distinct error before attempting AEAD). The encoder MUST NOT skip compression for empty plaintext and produce a 0-byte body — doing so creates a blob that decrypt_blob can decode successfully (AEAD passes on the correct key, decompression of a 0-byte body produces 0 bytes), but which is not conformant to this spec and is not byte-compatible with the reference implementation's encrypt output. A reimplementer who tests the empty-plaintext case using compress=false and assumes the same behavior applies to compress=true will miss this divergence.
Zstd (RFC 8878). On by default. The current implementation uses ruzstd's Fastest level (~zstd level 1); higher levels are not yet available in ruzstd 0.8.x. The compression level is not configurable — all blobs are compressed at the same level. Interop note: the compression level is not part of the wire format. Any valid zstd frame is acceptable on decompression regardless of the compression level used to produce it. A reimplementer using a higher compression level (e.g., zstd level 3) produces interoperable blobs — the decompressor does not know or care what level was used.
Size limit: 256 MiB maximum on native targets; 16 MiB on wasm32 targets (cfg(target_arch = "wasm32")). This limit is enforced on both the encrypt and decrypt paths. On encrypt, the core library returns InvalidData (not InvalidLength; oversized plaintext is a protocol-level size policy violation, not a type-level buffer size mismatch) when plaintext exceeds the platform's limit. On decrypt, decompressed output exceeding the limit triggers AeadFailed (not DecompressionFailed) — all post-AEAD errors are collapsed to prevent a 1-bit oracle that would reveal successful authentication (see §12 error-oracle collapse). The decrypt-side limit prevents OOM from maliciously crafted zstd payloads ("zip bomb" attacks). Enforced via decoder.take(MAX_DECOMPRESSED_SIZE + 1) followed by length check. Cross-platform note: a blob encrypted on native with plaintext between 16-256 MiB is permanently undecryptable on WASM (exceeds the WASM limit). WASM encryptors are capped at 16 MiB so they cannot create such blobs, but mixed-platform deployments must enforce the lower limit on the encryption side.
CAPI error on oversized plaintext — platform-dependent: The CAPI applies a blanket 256 MiB cap on all input buffers (§13.4), returning InvalidLength for any buffer exceeding that limit. On native targets, this CAPI cap fires before the core library's 256 MiB InvalidData check — so CAPI callers on native see InvalidLength for oversized plaintext, not InvalidData. On WASM targets, the core library's 16 MiB limit is smaller than the CAPI's 256 MiB cap, so the core check fires first and returns InvalidData. A reimplementer building a compatible CAPI should apply the general InvalidLength cap first, then let the core InvalidData check enforce the platform-specific limit.
Zstd level 1 expansion and conditional compression skip — AAD binding hazard: At compression level 1 (Fastest), zstd occasionally expands incompressible data (the compressed output is larger than the input). A reimplementer who skips compression when it would expand (i.e., uses the uncompressed plaintext if compressed_size >= original_size) MUST set flags = 0x00 in both the blob header and the AAD. If they set flags = 0x01 in the AAD (because they "attempted" compression) but store uncompressed plaintext in the ciphertext, AEAD authentication will succeed at decrypt but decompression of the uncompressed content will fail or produce garbage. Equivalently, if they set flags = 0x01 in the header and flags = 0x00 in the AAD, AEAD authentication fails immediately. The invariant: flags in the AAD MUST equal flags in the blob header, and BOTH must accurately reflect whether the encrypted content is zstd-compressed. There is no mechanism to "correct" the flags after AEAD seals the blob — the flags are bound to the ciphertext at encryption time.
Decompression is flag-driven, not content-sniffing: The decryptor checks flags bit 0 to determine whether to decompress — it does NOT inspect the plaintext for zstd magic bytes (0x28 0xB5 0x2F 0xFD) and attempt decompression speculatively. A reimplementer who sniffs content and decompresses any output that begins with zstd magic bytes diverges from the specification: an uncompressed blob whose plaintext happens to begin with those bytes would be incorrectly decompressed, producing garbage or a decompression error. The flags byte is the sole authority on whether decompression applies.
An empty compressed payload decompresses to an empty plaintext (decrypt side only — special-cased after AEAD decryption by checking whether the decrypted content is empty before calling zstd, which would otherwise reject the empty frame). The empty check fires on the post-AEAD plaintext bytes, not on the raw ciphertext body: flags=0x01 with a zero-byte post-AEAD payload is accepted; a reimplementer who checks for emptiness pre-AEAD (on the ciphertext) and rejects would silently refuse a class of valid blobs. On the encrypt side, zstd produces a non-empty frame even for empty input (zstd frame headers are always present), so an empty compressed payload can only appear in a blob produced outside the standard encrypt path.
encrypt_blob zstd expansion asymmetry with streaming: encrypt_blob does not enforce a zstd expansion guard — if zstd produces output larger than the input, the larger output is stored. This is asymmetric with stream_encrypt_chunk (§15.11), which returns Internal if zstd output exceeds plaintext.len() + STREAM_ZSTD_OVERHEAD. Implementations MUST NOT add the streaming guard to encrypt_blob for consistency — doing so returns Internal for incompressible inputs instead of storing them, breaking interoperability.
Compression oracle (CRIME/BREACH): Any API that compresses plaintext that an attacker can partially control and then reports the ciphertext size creates a compression oracle. If the caller can observe the size of the encrypted blob (e.g., as returned by encrypt_blob) and inject chosen text adjacent to a secret in the same compression context (e.g., the same blob or channel), the attacker can recover the secret byte-by-byte by correlating size changes with injected guesses. This is the CRIME/BREACH attack family. For encrypt_blob, the entire plaintext is compressed as a single unit before AEAD — if the plaintext mixes attacker-controlled data with secrets (e.g., a JSON blob with a user-controlled field alongside an authentication token), the compressed size reveals information about the secret. Callers MUST NOT include attacker-controlled data and secrets in the same encrypt_blob call without either disabling compression (compress=false) or ensuring the compression contexts are isolated. For community channel storage where all subscribers share the same channel key, this concern applies to any blob that mixes channel content from multiple trust levels. See §15.5 for the same analysis applied to the streaming layer, where the concern is more acute due to sequential chunk compression.
11.4 Storage AAD
11.4.1 Community Storage AAD
aad = "lo-storage-v1" // 13 bytes || version // 1 byte (key version) || flags // 1 byte (bit 0: compressed) || len(channel_id) || channel_id // UTF-8, 2-byte BE len || len(segment_id) || segment_id // UTF-8, 2-byte BE len
Total AAD size: 15 + 2 + len(channel_id) + 2 + len(segment_id) = 19 + len(channel_id) + len(segment_id) bytes. The fixed overhead is 19 bytes: 13 (label) + 1 (version) + 1 (flags) + 2 (channel_id length prefix) + 2 (segment_id length prefix). Quick-check: for 8-byte channel_id and 12-byte segment_id, the AAD is 39 bytes total.
Why version and flags are in the AAD: Binding version prevents an attacker from substituting a different key version's ciphertext (key confusion). Binding flags prevents flipping the compression bit after encryption — without this, an attacker could set the compression flag on uncompressed ciphertext, causing the decryptor to run zstd decompression on raw plaintext (producing garbage or a decompression bomb). The AAD authenticates the processing pipeline, not just the plaintext.
Why community storage AAD omits identity fingerprints: Community storage is channel-keyed, not user-keyed — blobs are shared channel content encrypted under the channel's storage key, not under any individual user's key. Binding sender/recipient fingerprints would require knowing the author's identity at both encrypt and decrypt time, which is not always available (e.g., bulk channel export, server-side re-encryption). The channel_id and segment_id provide the binding that matters for anti-relocation: blobs cannot be moved to a different channel or segment position. Why DM queue AAD binds recipient but not sender: DM queue blobs are server-held per-recipient caches. The recipient's identity is always known at both encrypt and decrypt time (it is the key owner). The sender's identity is not reliably available at decrypt time — a server processing a DM queue does not necessarily know which sender produced each blob. Binding only the recipient prevents a relay from substituting one recipient's queued messages into another recipient's slot, without requiring sender identity tracking. A reimplementer who adds sender fingerprints to community storage AAD or DM queue AAD produces blobs that cannot be decrypted by the standard implementation — the AAD mismatch causes silent AeadFailed.
No Unicode normalization: All string identifiers (channel_id, segment_id, batch_id, recipient_fp) are raw UTF-8 bytes with no normalization applied. "café" encoded as NFC (U+00E9) and NFD (U+0065 U+0301) produce different AAD and thus decryption failure. Languages with automatic string normalization (Swift String normalizes to NFC; macOS filesystem APIs may normalize paths) must preserve the original byte representation.
len() is byte length of the UTF-8 encoding: The 2-byte BE length prefix encodes the byte count of the UTF-8-encoded string — not the character count, UTF-16 code-unit count, or code-point count. A 4-byte emoji (U+1F600) has byte-length 4, character-count 1, and UTF-16-unit-length 2. Implementations that call string.length() in Java, C#, or JavaScript receive the UTF-16 unit count and MUST convert to byte count (e.g., string.getBytes(UTF_8).length in Java, Encoding.UTF8.GetByteCount(s) in C#, Buffer.byteLength(s, 'utf8') in Node.js) before writing the prefix. Passing the wrong count shifts all subsequent fields and produces permanent AEAD failure with no diagnostic.
UTF-8 validation: All string fields (channel_id, segment_id, batch_id) MUST be validated as well-formed UTF-8 before inclusion in the AAD. Invalid UTF-8 → InvalidData. In Rust, channel_id: &str (and likewise segment_id: &str, batch_id: &str) guarantees valid UTF-8 at the type level — no explicit from_utf8() check is required or present in the reference implementation; the Rust type system enforces it. In C, Go, and other language bindings, the caller must perform an explicit check — Go's string([]byte{0xFF}) accepts arbitrary bytes and does NOT validate UTF-8; C has no built-in UTF-8 validation. Without this check, two callers passing the same logical string through different byte-level representations (one with an invalid continuation byte silently substituted) produce different AAD and silent AEAD failure on decrypt. CAPI callers and non-Rust bindings MUST validate UTF-8 before passing string fields to the library.
Oversized identifier error asymmetry: When channel_id or segment_id exceeds 65,535 bytes (the maximum representable value of the 2-byte BE length prefix), build_storage_aad returns InvalidData. On the encrypt path this propagates directly as InvalidData. On the decrypt path it is remapped to AeadFailed — returning InvalidData from a decrypt call would leak that the rejection occurred at AAD construction before AEAD was attempted, revealing that the identifier exceeded the length limit rather than that authentication failed. Callers must not interpret AeadFailed on decrypt as proof that the ciphertext was structurally valid — an oversized identifier produces the same error as a tampered blob.
Oversized batch_id error asymmetry (§11.4.2): The same encrypt→InvalidData / decrypt→AeadFailed asymmetry documented above for channel_id and segment_id applies equally to batch_id. When batch_id exceeds 65,535 bytes (the maximum representable value of the 2-byte BE length prefix in the DM queue AAD construction), InvalidData is returned on the encrypt path and AeadFailed on the decrypt path. The rationale is identical to the community storage asymmetry above.
Zero-length channel_id and segment_id are valid: The len(x) || x encoding permits zero-length strings — a zero-length channel_id encodes as 0x0000 (2-byte BE prefix with no subsequent bytes). The library accepts zero-length IDs on both encrypt and decrypt paths. Reimplementers MUST NOT add a non-empty guard on these fields; doing so produces InvalidData on encrypt and AeadFailed on decrypt for blobs where an empty string was used as the identifier, creating a silent interoperability break with any implementation following this spec.
11.4.2 DM Queue AAD
aad = "lo-dm-queue-v1" // 14 bytes || version // 1 byte (key version) || flags // 1 byte (bit 0: compressed) || len(recipient_fp) || recipient_fp // 32 bytes, recipient identity fingerprint (length-prefixed despite being fixed-size; a reimplementer who uses bare encoding — by analogy with §7.4's fixed-size fields — produces different AAD bytes and silent AEAD failure on every message) || len(batch_id) || batch_id // UTF-8, 2-byte BE len
Total AAD size: 14 + 1 + 1 + 2 + 32 + 2 + len(batch_id) = 52 + len(batch_id) bytes. For a UUID4 batch_id (36 ASCII characters), the total AAD is 88 bytes.
batch_id MUST be unique per (recipient_fp, key_version) pair: The batch_id is the sole per-batch domain separator in the DM queue AAD. Two blobs with the same recipient_fp, key_version (the version byte in the header), and batch_id but different content share the same AAD — an attacker who can observe or influence both blobs can use the colliding AAD to mount an integrity substitution attack: because AEAD authentication binds the AAD, a ciphertext-and-tag pair that is valid under one blob's (nonce, key, AAD) is also valid when presented under any other blob with the same AAD and the same key. In practice, nonces are random and different per blob, so valid tag reuse across blobs requires nonce collision (negligible); however, colliding AADs mean the AEAD tag provides no binding between the ciphertext and which specific batch it belongs to — an attacker with write access to the store can substitute blob A for blob B if both share the same AAD, and the recipient's AEAD will accept it. Distinct batch_id values prevent this by making each batch's AAD unique, ensuring that a ciphertext produced for batch A cannot authenticate as batch B. batch_id SHOULD be a value that is unique per batch: a UUID4 (random_bytes(16) formatted as UUID), a monotonic counter, or a timestamp with sufficient resolution. The reference implementation does not generate batch_id automatically — it is caller-supplied. A server that reuses batch_id across different message batches to the same recipient under the same key version creates colliding AADs.
recipient_fp is a raw 32-byte binary value: recipient_fp is the raw SHA3-256 digest of the recipient's LO composite public key (32 bytes of binary data) — not a hex string, not a UTF-8 encoding, not a Base64 value. It is concatenated directly into the AAD with no encoding step and no additional length delimiter beyond the len() prefix shown above. Unlike batch_id (which is a UTF-8 string requiring byte-length encoding) and unlike the display fingerprint (a 64-character hex string returned by GenerateIdentity), recipient_fp is always 32 raw bytes. The surrounding §11.4 discussion of UTF-8 validation and byte-length semantics applies only to string fields (channel_id, segment_id, batch_id) — it does not apply to recipient_fp.
DM queue blob size cap: DM queue blobs (soliton_dm_queue_encrypt / soliton_dm_queue_decrypt) inherit the CAPI input size cap from §13.2: 256 MiB (268,435,456 bytes) on standard (non-WASM) targets; 16 MiB (16,777,216 bytes) on WASM targets (where allocation constraints are tighter). Inputs exceeding the applicable cap return InvalidLength. The WASM cap is enforced by the WASM binding layer, not the core library — the core library applies only the 256 MiB cap. Implementations targeting WASM MUST apply the 16 MiB cap before calling into the core library. In practice, DM queue blobs are bounded by the application-layer message size limit (LO Protocol §15.1 mandates padding to a fixed maximum message size), which is well below either cap.
11.5 Storage Layout
<backend>/<channel_id>/<yyyy-mm-dd>/segment-<N>.blob
- Partitioned by channel and date for efficient retention purging.
- Segments are append-only encrypted chunks, numbered sequentially within a day.
- New segment when current exceeds
batch_size.batch_sizeis an application-defined threshold (not a library constant) — the maximum number of messages stored in a single segment file before rolling over to a new one. A typical value is 1,000 messages; larger values increase the decryption cost when accessing old messages (the entire segment must be decrypted to retrieve any message within it). - S3: shallow, predictable prefixes.
segment_id AAD value: The segment_id field in community storage AAD (§11.4.1) is the <yyyy-mm-dd> directory name for channels with at most one segment per day (e.g., "2024-03-15", matching the test vector in Appendix F.8). The date MUST be ISO 8601 with zero-padded month and day: "2024-03-05" for March 5th — NOT "2024-3-5". Two implementations using different date formatters (one zero-padded, one not) produce different AAD bytes and silent AeadFailed on every cross-implementation decrypt. For channels with multiple segments per day, segment_id MUST include both the date and the sequence number to prevent AAD collisions — e.g., "2024-03-15/segment-42" (separating date and filename with /) or another unambiguous application-defined format, provided both encrypt and decrypt use the same convention. Using the bare date for multi-segment days means all blobs on that day share the same segment_id and therefore the same AAD — different key versions (the version byte) prevent key confusion, but the position binding is weaker. The channel_id AAD value is the bare channel identifier string, not any path prefix.
segment_id is external caller-supplied context — not stored in the blob: The segment_id value is never encoded inside the encrypted blob. It must be reproduced identically at decrypt time from external metadata (e.g., the file path, directory name, or database record identifying this segment). A reimplementer who assumes segment_id is derivable from the ciphertext will produce a wrong AAD at decrypt time and receive AeadFailed with no diagnostic. Both the encrypt and decrypt calls MUST supply the same segment_id string — the AEAD tag authenticates it as part of AAD, so any mismatch causes authentication failure.
No canonical multi-segment segment_id format: This specification does not standardize a multi-segment segment_id format. The example "2024-03-15/segment-42" is illustrative, not normative. Any format that uniquely identifies the date and segment position within a channel is acceptable, provided it is applied consistently on the encrypt and decrypt sides. Deployments that define their own format (e.g., "2024-03-15_42", "20240315-042") are specification-conformant as long as the same convention is used throughout.
11.6 Key Rotation
Multiple decryption keys active simultaneously, identified by version byte (1-255).
LO_STORAGE_KEY_V1 = <64 hex chars> # 256-bit key — MUST be generated from the OS CSPRNG
LO_STORAGE_KEY_V2 = <64 hex chars>
# Generate: openssl rand -hex 32
Storage key MUST be generated from the OS CSPRNG: unlike the per-blob nonce (§11.1, explicitly random_bytes(24)), the storage key is a long-lived 256-bit symmetric key used to encrypt every blob under that version. The key MUST be generated using the OS CSPRNG (openssl rand -hex 32, random_bytes(32), or equivalent). A key derived from a deterministic schedule, a counter, a password without KDF stretching, or any non-CSPRNG source reduces security to the entropy of that source. A KDF-derived key (e.g., HKDF from a master key) is acceptable only if the master key itself was CSPRNG-generated and the derivation is documented. The openssl rand -hex 32 example above is the recommended generation command; any OS-level CSPRNG invocation (/dev/urandom, getrandom(2), CryptGenRandom, SecRandomCopyBytes) is equivalent.
Procedure:
- Generate new key for version N+1.
- Provide at boot:
LO_STORAGE_KEY_V{N+1}environment variable. - Update config:
active_version = N+1(live reload). - New writes tagged V(N+1). Old reads use version byte to select key.
- Optional: re-encrypt old segments (read with old key, write with new).
- After migration: remove old key on next restart. Warning: if step 5 is skipped, removing the old key makes all blobs encrypted under that version permanently undecryptable — there is no grace period, soft delete, or recovery mechanism. The version byte in each blob identifies which key decrypts it; without that key in the keyring,
decrypt_blobreturnsAeadFailed. Callers MUST either complete re-encryption before key removal or accept permanent data loss for unrewritten blobs.
Key validation: StorageKey::new(version, key_bytes) rejects version 0 with UnsupportedVersion (version 0 is reserved — consistent with encountering version 0 in a blob's version byte during decryption) and rejects all-zero key material with InvalidData via constant-time comparison (ct_eq). An all-zero key is never legitimate — with the key known to any observer, the XChaCha20 keystream is publicly computable and all encrypted blobs are trivially decryptable. (Nonces are CSPRNG-generated independently of the key value; the rejection is not about nonce determinism.) Caller zeroization obligation: key_bytes is [u8; 32] — a Copy type. StorageKey::new receives a bitwise copy of the caller's array and zeroizes its own copy on rejection paths. The caller's original array is a separate copy that is NOT zeroized by the library. After calling StorageKey::new, the caller MUST explicitly zeroize their copy of key_bytes (Rust: key_bytes.zeroize(); C: soliton_zeroize(key_ptr, 32)). The general [u8; N] Copy zeroization pattern is described in §10.5; StorageKey::new is a specific instance of it.
add_key atomicity — active_version update and map insert: The add_key function sets active_version = version and inserts the key into the map. The Rust reference implementation assigns active_version BEFORE the map insert — this is safe in Rust because HashMap::insert is infallible (it cannot throw or return an error). Go's map[V]K insert and CPython's dict insert are also infallible — the assign-before-insert ordering is safe in both and requires no reordering. Reimplementers in languages where map insert CAN throw or fail (Java HashMap, C# Dictionary, custom MutableMapping subclasses in Python) MUST use the opposite ordering: assign active_version only AFTER the insert returns success. In these languages, if the map insert throws after the active_version assignment, active_version points to a missing key, breaking the active_key() never-None invariant — every subsequent encrypt_blob call returns InvalidData with no diagnostic. The safe rule: assign active_version after insert in any language where the map insert can raise an exception or return an error; assign before insert only when insert is unconditionally infallible.
Keys are held in process memory for the lifetime of the StorageKeyRing object and are zeroized on drop — when the StorageKeyRing is dropped (end of scope in Rust; explicit destruction in CAPI via the free function), all key material in the key list is zeroized before deallocation. CAPI callers MUST call the keyring free function after use; failing to do so leaks key material (the allocation is freed but the key bytes are not zeroed). Keys are not persisted automatically — the caller is responsible for reloading keys (e.g., from environment variables or a secrets manager) at startup. Environment variables should be cleared from the process environment immediately after reading.
Security risk of retaining old keys: Step 6 of the rotation procedure (removing the old key) is a security obligation, not only a hygiene concern. An old key that remains in the keyring — even after all blobs have been re-encrypted under the new key — provides an attacker who later compromises that old key with the ability to substitute old blobs back into storage. If an attacker has write access to the blob store and possesses a compromised old key, they can replace new-version blobs with old-version blobs encrypted under the compromised key; the keyring will decrypt them successfully (the version byte routes to the retained old key). Retaining old keys long after migration extends the window during which a key compromise enables replay of stale content. The recommended pattern: remove old keys promptly after re-encryption is complete, and treat the Ok(false) return from remove_key (key was already absent) as confirmation, not an error.
remove_key behavior: Removes a key version from the keyring's in-memory list and returns Ok(true) if the key was present, Ok(false) if it was absent (idempotent — removing a non-existent version is a no-op, not an error). remove_key(0) returns UnsupportedVersion (version 0 is reserved — same as StorageKey::new(0)). The active version cannot be removed (returns InvalidData). This is immediate in-memory removal, not deferred deletion — subsequent decrypt calls using that version will return AeadFailed (not UnsupportedVersion, to prevent error-oracle attacks that could distinguish "removed key" from "corrupted ciphertext"). Version tracking is the caller's responsibility; the keyring does not persist removal history.
add_key return value: add_key(version, key_bytes, make_active) returns Ok(true) if a key at that version already existed and was replaced (prior material is destroyed — any blobs encrypted under the old key material at that version are permanently undecryptable), Ok(false) if the version was new. make_active=false with a version matching the current active version returns InvalidData (see §13.2 for the CAPI note). The Ok(true) case is a silent overwrite of key material — callers who need to know whether a key was replaced should inspect the return value before adding.
add_key(make_active=true) with the current active version replaces key material in-place: When make_active=true and version equals the current active version, add_key succeeds (Ok(true)) and replaces the active key's material with the new bytes. The make_active=false-with-active-version guard does NOT fire in this case (that guard prevents the specific inconsistency of changing the active pointer without updating the material). The result: every subsequent encrypt_blob call uses the new key material, and any blobs previously encrypted with the old material at that version are permanently undecryptable — the old material is destroyed in-place with no grace period. This operation is only safe when all blobs at that version have already been migrated (re-encrypted) under a different version. The recommended rotation pattern (§11.6 step 3) avoids this hazard by always using a new version number when adding a replacement key.
StorageKeyRing thread safety model: StorageKeyRing auto-derives Send + Sync (all fields are Send + Sync) but is not designed for concurrent access. There is no internal Mutex — the struct is a plain HashMap<u8, StorageKey> with an active_version: u8 field. Mutating operations (add_key, remove_key) take &mut self; concurrent mutation requires exclusive access enforced by the caller, not the library. encrypt_blob and decrypt_blob do NOT take a &StorageKeyRing reference — the caller retrieves the active key via ring.active_key() and passes the resulting &StorageKey directly to encrypt_blob. The CAPI SolitonKeyRing wrapper adds an AtomicBool reentrancy guard that returns ConcurrentAccess (-18) on re-entrant calls from a single thread — this is a single-thread reentrancy guard, not a multi-thread Mutex. Correct concurrent model for reimplementers: wrap StorageKeyRing in a caller-owned Mutex<StorageKeyRing>. Encrypt/decrypt and key management all require exclusive lock acquisition, since getting the key reference (active_key()) and passing it to encrypt_blob are two separate operations that must not be split by a concurrent add_key/remove_key. A RwLock where encrypt/decrypt acquire read locks and key management acquires a write lock is unsafe because active_key() returns a reference into the inner HashMap — the reference is invalidated if any write-lock operation (add_key/remove_key) rehashes the map.
active_key() never-None invariant: The keyring is constructed with an initial key (new(version, key_bytes)), remove_key rejects removal of the active version, and add_key(make_active: true) atomically replaces the active version. These three constraints ensure active_key() always returns Some. In Rust, the Option<&StorageKey> return type is never None by construction. Binding authors implementing a keyring outside Rust's type system MUST maintain this invariant — if violated, encrypt_blob has no key to encrypt with and returns InvalidData with no diagnostic pointing to the empty keyring. The invariant should be checked at construction time (reject an empty or zero-version keyring) rather than at each encrypt_blob call.
12. Error Types
All soliton operations return a Result<T, Error>. The following error variants are defined:
| Variant | C code | Meaning | Recoverability |
|---|---|---|---|
InvalidLength |
-1 | Input has wrong size for the expected key or buffer type. Rust struct variant: InvalidLength { expected: usize, got: usize } — NOT a unit variant. Pattern-matching must bind both fields (or use ..); constructing it requires both fields. The error message is "invalid length: expected {expected}, got {got}". Internal truncation errors that would expose parser offset information use InvalidData instead, not InvalidLength — InvalidLength is reserved for caller-supplied parameters that don't match a known fixed size. |
Caller bug |
DecapsulationFailed |
-2 | KEM decapsulation failed — unreachable in lo-crypto-v1 (see note below). Exists for forward compatibility with explicit-rejection KEMs. | Retry-safe (decrypt) |
VerificationFailed |
-3 | Signature verification failed | Retry-safe |
AeadFailed |
-4 | AEAD decryption failed (wrong key, tampered ciphertext, or wrong AAD) | Session-fatal (encrypt); retry-safe (decrypt) |
BundleVerificationFailed |
-5 | Pre-key bundle IK mismatch or signature invalid | Retry-safe (fetch new bundle) |
TooManySkipped |
-6 | (reserved — was skip cache overflow, removed in counter-mode redesign) | — |
DuplicateMessage |
-7 | Message counter already in recv_seen (already decrypted). Caller guidance: silently discard the duplicate — no application-level notification, no retry. MUST NOT surface this error to the message sender: an attacker who can distinguish DuplicateMessage from AeadFailed gains a membership-oracle on the receiver's recv_seen set, enabling byte-by-byte probing of which counters have been decrypted. Treat as an opaque "already delivered" signal at the transport layer. |
Retry-safe (state unchanged) |
| (reserved) | -8 | Was SkippedKeyNotFound, removed |
— |
AlgorithmDisabled |
-9 | (reserved — intended for platform-specific algorithm availability; currently unused) | — |
UnsupportedVersion |
-10 | Serialized blob has unknown version byte. Source functions: from_bytes/from_bytes_with_min_epoch (ratchet blob version ≠ 0x01); stream_decrypt_init (stream header version ≠ 0x01); StorageKey::new (key version = 0 is reserved). Not returned from decrypt_blob for unknown blob version — that case collapses to AeadFailed (version-enumeration oracle). |
Permanent |
DecompressionFailed |
-11 | Zstandard decompression failed, or decompressed size exceeds 256 MiB (collapsed to AeadFailed at trust boundaries — see note below) |
Retry-safe |
Internal |
-12 | Structurally unreachable internal error. Also returned from stream_encrypt_chunk if zstd produces expansion beyond plaintext.len() + STREAM_ZSTD_OVERHEAD (§15.11) — the overhead ceiling is additive over the actual plaintext length, not over CHUNK_SIZE; a 100-byte final chunk that compresses to more than 356 bytes (100 + 256) triggers Internal, not a 1 MiB + 256 ceiling. Encrypt-side only (no oracle concern), not session-fatal. Recovery requires a full stream restart: compress is fixed at stream_encrypt_init — there is no per-chunk compress parameter to toggle. Retrying the same chunk on the same encryptor fails deterministically with the same result (the zstd output for that plaintext is fixed). Recovery requires abandoning the current encryptor, creating a new one via stream_encrypt_init with compress = false, and re-encrypting the stream from the beginning. encrypt() does not return Internal on CSPRNG failure. random_bytes() panics on OS CSPRNG unavailability, and panic = "abort" converts that panic into process termination — no error is propagated to the caller. CSPRNG failure is treated as non-recoverable by design: there is no safe fallback from an unusable entropy source, and a panicking abort is preferable to silently deriving keys from predictable "random" bytes. Also returned from soliton_argon2id if the underlying argon2 library returns an unexpected error not mappable to any other variant — structurally unreachable with a correct argon2 implementation and valid parameters. |
Context-dependent (see notes) |
NullPointer |
-13 | CAPI null pointer argument (C ABI only) | Caller bug |
UnsupportedFlags |
-14 | Reserved for storage blobs with reserved flag bits set. Never constructed in the current implementation — reserved-flag rejections are collapsed directly to AeadFailed without producing this variant (see oracle-collapse note below). Retained solely for ABI stability: error code -14 MUST NOT be reassigned. |
Permanent |
Error oracle collapse (defense-in-depth): In the decrypt path,
DecompressionFailedandUnsupportedFlagsare collapsed toAeadFailedbefore returning to the caller. Distinct error codes for post-AEAD parsing steps would let an attacker distinguish "AEAD passed but decompression failed" from "AEAD failed," leaking a decryption oracle. The CAPI maps both toSOLITON_ERR_AEAD (-4). The distinct codes above are retained solely for ABI stability and are never exposed across trust boundaries.UnsupportedFlagsis never constructed in the current implementation — reserved-flag rejections are mapped directly toAeadFailed.Consolidated collapse table:
Internal Error Exposed As Context Reason DecompressionFailedAeadFailedStorage (§11.3) Post-AEAD parsing oracle UnsupportedFlags(reserved bits)AeadFailedStorage (§11.3) Reserved-bit oracle on pre-AEAD header field (flags byte is parsed before AEAD; a distinct error leaks that the rejection was structural, not cryptographic) DecompressionFailedAeadFailedStreaming (§15.7) Post-AEAD decompression oracle Reserved flag bits (stream header) AeadFailedStreaming (§15.8) Header field oracle (attacker-controlled) Size mismatch after decompress AeadFailedStreaming (§15.7) Post-AEAD size oracle Key version not in keyring at decrypt time AeadFailedStorage decrypt (§11.3) Version-enumeration oracle — returning UnsupportedVersionfor an unregistered version byte would let an attacker distinguish "key not loaded" from "wrong ciphertext"Undersize ciphertext (< 16 bytes) AeadFailedAEAD decrypt (§7.1) Too-short-vs-bad-tag oracle — using InvalidLengthwould let an attacker distinguish "ciphertext shorter than Poly1305 tag" from "valid-length but wrong tag"Storage blob shorter than 42 bytes AeadFailedStorage decrypt (§11.1) Pre-AEAD framing oracle — 42 bytes is the minimum valid blob (26-byte header + 16-byte Poly1305 tag); using InvalidLengthorInvalidDatawould let an attacker distinguish "blob too short to contain valid ciphertext" from "plausible-length blob with wrong key/tag"ChainExhaustedfromfrom_bytesChainExhausted(notInvalidData)Deserialization (§6.8) The blob's stored epoch is u64::MAX— the resulting state cannot be re-serialized (to_byteswould overflow onepoch + 1). States with stored epochu64::MAX - 1are accepted butcan_serialize()returns false, preventingto_bytesfrom producing a zombie blob. This is a counter-exhaustion condition, not a format error.Streaming chunk input shorter than STREAM_CHUNK_OVERHEAD= 17 bytesAeadFailedStreaming decrypt (§15) Pre-AEAD framing oracle — a 17-byte minimum is tag_byte (1) + Poly1305 tag (16), the smallest structurally valid chunk with zero-length plaintext. ReturningInvalidLengthorInvalidDatafor a sub-17-byte chunk would let an attacker distinguish "chunk too short to attempt AEAD" from "plausible-length chunk with wrong key/tag." This parallels the "Undersize ciphertext (< 16 bytes) → AeadFailed" rule for raw AEAD, with the streaming layer adding 1 byte for the tag_byte. Note: §15.7 describes the oracle-collapse scope as "post-authentication errors" — this pre-AEAD check is the streaming-layer analogue of the raw AEAD undersize collapse, not a post-auth error; both collapse toAeadFailedfor the same oracle-prevention reason.|
DuplicateMessage|AeadFailed(toward sender) | Ratchet decrypt (§6.6) | Replay-detection oracle — an attacker who can distinguishDuplicateMessagefromAeadFailedgains a membership oracle on the receiver'srecv_seenset, enabling byte-by-byte probing of which counters have been decrypted.DuplicateMessageMUST NOT be surfaced to the message sender; the transport layer MUST treat it as an opaque "already delivered" signal and silently discard the duplicate. |Not collapsed (checked on public/pre-AEAD data):
UnsupportedVersion(version byte is cleartext),InvalidDatafor pre-AEAD framing checks (chunk wire length is observable). |ChainExhausted| -15 | Five distinct recoverability modes: (1) Encrypt-side (send_count at u32::MAX): session-fatal for the send direction — no more messages can be sent. Source:encrypt()/soliton_ratchet_encryptonly. (2) Decrypt-side recv_seen saturation (§6.8): transient — therecv_seenorprev_recv_seenset is full (65536 entries); the cap resets on the next KEM ratchet step (peer triggers direction change). A caller who treats allChainExhaustedfromdecrypt()as session-fatal will terminate a recoverable session. Source:decrypt()/soliton_ratchet_decryptonly. (3) Serialization epoch overflow (to_bytesat epoch u64::MAX, §6.8 guard 24): persistence-fatal — the in-memory session remains functional for send/receive but can never be serialized again. Source:to_bytes()/soliton_ratchet_to_bytesonly. Also returned byfrom_bytes()/soliton_ratchet_from_bytes_with_min_epochwhen the deserialized epoch equalsu64::MAX(guard 24 — the session cannot be serialized again; §6.8). (4) Call chain advance limit (§6.12):CallKeys::advance()returnsChainExhaustedafter 2²⁴ steps; the call session is permanently exhausted and a newderive_call_keys()call is required. Unrelated to ratchet message counters. Source:CallKeys::advance()/soliton_call_keys_advanceonly. (5) Streaming chunk index exhaustion (§15.9): returned byencrypt_chunkordecrypt_chunk(sequential) whennext_index == u64::MAX. Not session-fatal — the handle is still valid and can be freed normally. Distinct from the ratchet modes: a streamingChainExhausteddoes NOT indicate any ratchet problem. Source:soliton_stream_encrypt_chunk/soliton_stream_decrypt_chunkonly;soliton_stream_decrypt_chunk_atnever returns this. | See per-mode description | |UnsupportedCryptoVersion| -16 |crypto_versionfield in a session init is not "lo-crypto-v1". Source functions:decode_session_initandreceive_session.receive_session(§5.5 Step 1) performs its owncrypto_versioncheck against the parsedSessionInitbefore signature verification — it returnsUnsupportedCryptoVersiondirectly (not collapsed, because §5.5's checked values are public; see the error-collapsing note in §5.5).decode_session_initreturns it during wire-format parsing. Not returned byverify_bundle— a wrongcrypto_versionin a pre-key bundle is collapsed toBundleVerificationFailed(§5.3 error-collapsing paragraph) along with fingerprint mismatches and signature failures, to prevent enumeration of which check failed. Not returned by the ratchet or storage layers. A binding author who pattern-matches forUnsupportedCryptoVersionfromdecode_session_initbut not fromreceive_sessionwill miss the second source. | Permanent | |InvalidData| -17 | Structural violation in serialized data or caller protocol misuse. Covers: bad marker bytes, co-presence errors, implausible values in deserialized blobs (ratchet, session-init); and caller misuse on the streaming API — callingencrypt_chunkordecrypt_chunkafter finalization, passing a wrong-size non-final chunk (uncompressed), or passing an oversized final chunk plaintext. Binding authors MUST NOT assume this error always indicates corrupt received data; it may indicate a caller-side state machine bug. | Retry-safe | |ConcurrentAccess| -18 | Opaque handle is being freed while another thread holds a reference (CAPI-only — not present in the coreErrorenum; exists only as a CAPI error code) | Caller bug |
DecapsulationFailed is unreachable in lo-crypto-v1 — two blocked paths: This variant is structurally unreachable because both sites that could produce it are blocked:
-
encode_ratchet_header/decode_ratchet_header: Any KEM ciphertext (kem_ct) with the wrong size (not exactly 1120 bytes) is rejected asInvalidDataduring header parsing, before X-Wing decapsulation is attempted. A malformed ciphertext never reachesxwing::decapsulate. -
xwing::decapsulateitself: X-Wing uses implicit rejection (§8.4): if ML-KEM decapsulation detects a "garbage ciphertext" condition (J ≠ 0), it substitutes a pseudo-random shared secret (z XOR H(ciphertext)) rather than returning an error. X25519 always produces a result (all-zero output is handled separately by the low-order point check).xwing::decapsulatetherefore always returnsOk(shared_secret), neverErr(DecapsulationFailed).
The variant is retained in the enum for ABI stability (-2 is reserved) and for future explicit-rejection KEMs. Binding authors may safely treat DecapsulationFailed from the current library as Internal — it indicates a logic error, not a recoverable condition.
Error is #[non_exhaustive]: The Error enum is marked #[non_exhaustive] in Rust, meaning match arms must include a catch-all (_ => ...). Binding authors and application code MUST NOT exhaustively match on error codes — a future version may add new variants without incrementing the library's major version. New variants MUST get new numeric codes (not reuse reserved slots). The #[non_exhaustive] attribute also prevents binding authors from constructing Error values directly; use the library's entry points.
Recoverability key: Retry-safe — the operation can be retried or the message dropped; ratchet state is unchanged on error. Session-fatal — the session (or encrypt direction) is permanently broken; AeadFailed on encrypt triggers full key zeroization as defense-in-depth, making the session irrecoverable. Permanent — the error reflects a capability or format gap, not a transient condition. Caller bug — indicates a programming error in the calling code.
InvalidLength is for type-level size mismatches (wrong key size, wrong ciphertext size). InvalidData is for structural content violations (bad format, co-presence invariant broken, implausible values). The distinction matters for diagnostics.
InvalidData from _free functions means wrong handle type, not blob corruption: When soliton_ratchet_free, soliton_keyring_free, soliton_call_keys_free, soliton_stream_encrypt_free, or soliton_stream_decrypt_free return InvalidData (-17), it indicates the opaque handle's internal type discriminant is wrong — the handle pointer belongs to a different handle type (e.g., a SolitonKeyRing* was passed to soliton_ratchet_free). This is distinct from InvalidData returned by decryption or deserialization functions, where it means structurally invalid content. Binding authors writing diagnostic or error-handling code for _free functions should map InvalidData to "handle type mismatch" rather than "corrupted data."
Error code ABI stability: Once a numeric error code is assigned (e.g., -6 for TooManySkipped), that code is reserved forever — even if the error variant is removed or renamed. Binding authors hardcode these values in constants, switch statements, and documentation. Reassigning a code to a different error would silently change the meaning of existing bindings without compilation or test failures. Removed codes are marked "reserved" in the table above and must never be reused.
13. C ABI (soliton_capi)
13.1 Overview
soliton_capi exposes the core library as a stable C ABI (extern "C" functions). Direct consumers: Go (cgo), C# (P/Invoke), Dart (dart:ffi), C/C++. Swift and Kotlin consume the CAPI indirectly via UniFFI-generated wrappers. Node.js uses napi-rs (a Rust-native Node add-on API that does not call through the C ABI).
The generated header is soliton.h. It is produced by cbindgen and must not be edited manually.
13.2 Conventions
- Return codes:
0= success, negative = error (see §12 for codes). - Caller-allocated output buffers: Used when output size is a fixed compile-time constant (e.g., 32-byte fingerprints, 32-byte shared secrets). The caller passes a pre-allocated buffer; on error the buffer is zeroed.
- Library-allocated buffers (
SolitonBuf): Used for variable-length outputs. Must be freed withsoliton_buf_free. Never callfree()directly onptr. - Opaque heap objects (
SolitonRatchet*,SolitonKeyRing*): Allocated by the library, freed with their respective_freefunctions. - CSPRNG failure aborts the process: All keygen and encapsulation operations that consume OS entropy (
getrandom(2),ProcessPrng,getentropy, etc.) abort the process on CSPRNG failure rather than returning an error code — there is no safe cryptographic fallback when randomness is unavailable. This behavior is by design and is not configurable. Binding authors: do NOT wrap CAPI calls in a catch-all exception handler or POSIX signal handler expecting to recover from abort — the abort is deliberate and the process state after a failed CSPRNG call is not safely continuable. C++ callers:extern "C"functions MUST NOT propagate exceptions across the FFI boundary (undefined behavior per the C++ standard); the abort-on-CSPRNG-failure guarantee depends on no exception reaching the FFI boundary from within the library. A C++ wrapper that installs astd::terminatehandler or catches SIGABRT will mis-handle this. - All pointer arguments must be non-null unless documented otherwise. Null pointers return
NullPointer(-13). Exception (empty plaintext for encrypt only):soliton_stream_encrypt_chunkacceptsplaintext = NULLwithplaintext_len = 0— this is the mechanism for producing an empty final chunk (valid empty-file stream).soliton_stream_decrypt_chunkdoes NOT share this exception: its ciphertext input is namedchunk(notplaintext) and nullchunkis always rejected withNullPointer, even withchunk_len = 0. Binding wrappers that add blanket non-null guards on the encrypt-side plaintext pointer break the empty-file use case silently (the null check fires before the zero-length check, returningNullPointerwhere the empty chunk would succeed). Wrappers that apply the same exception to the decrypt-side chunk pointer diverge from the reference — the reference returnsNullPointerfor null chunk unconditionally. Exception (empty AAD):soliton_stream_encrypt_initandsoliton_stream_decrypt_initacceptcaller_aad = NULLwithaad_len = 0— this is the mechanism for streams with no additional authenticated data. The AAD defaults to empty, and HMAC domain separation is provided by the stream key and base nonce. Binding wrappers that add blanket non-null guards oncaller_aadreturnNullPointerfor valid empty-AAD calls. Exception (zero-length primitive inputs):soliton_hmac_sha3_256,soliton_hkdf_sha3_256, andsoliton_argon2idaccept a NULL pointer for any input whose corresponding length field is 0. Specifically:key = NULLwithkey_len = 0(HMAC with empty key),data = NULLwithdata_len = 0(HMAC/HKDF with empty data/IKM),salt = NULLwithsalt_len = 0(HKDF/Argon2id with empty salt),password = NULLwithpassword_len = 0(Argon2id with empty password),info = NULLwithinfo_len = 0(HKDF with empty info). These are valid degenerate inputs to the underlying primitives — HMAC(key=∅, data), HKDF with empty IKM or salt, and Argon2id with empty password are all well-defined by their respective RFCs. The null-with-nonzero-length combination still returnsNullPointer. Binding wrappers that add blanket non-null guards on these input pointers break the empty-input use case for primitive APIs where the caller explicitly wants to derive from an empty string. This exception does NOT apply to output buffers, key parameters with implicit fixed sizes (e.g., thekeyinsoliton_aead_encrypt), or any parameters not enumerated here. - Zero-length byte arrays: Most CAPI functions reject non-null pointers with zero length as
InvalidLength. Exception (zero-length ciphertext to decrypt operations):soliton_ratchet_decrypt,soliton_ratchet_decrypt_first,soliton_stream_decrypt_chunk, andsoliton_stream_decrypt_chunk_atreturnAeadFailed(notInvalidLength) for inputs shorter than their respective AEAD minimums (16 bytes for ratchet, 40 bytes for first-message, 17 bytes for streaming) — collapsing toAeadFailedprevents an oracle distinguishing "too short to attempt AEAD" from "wrong key." See §12 collapse table.soliton_stream_decrypt_chunk_atshares the same collapse because it calls the same underlyingdecrypt_chunk_innerpath assoliton_stream_decrypt_chunk. Binding wrappers that add zero-length short-circuit guards on these ciphertext inputs may returnInvalidLengthwhere the library returnsAeadFailed, breaking the oracle-collapse guarantee.soliton_aead_decryptwith zero-length ciphertext is NOT in this exception:soliton_aead_decryptwithciphertext_len = 0returnsInvalidLength(the CAPI zero-length guard fires before the core AEAD minimum check).ciphertext_lenvalues 1-15 returnAeadFailed(too short to contain the 16-byte Poly1305 tag, but non-zero length passes the CAPI guard). A reimplementer who applies the ratchet/stream pattern tosoliton_aead_decryptand returnsAeadFailedforlen = 0diverges from the reference. - Input size cap: All CAPI functions reject any single input buffer exceeding 256 MiB (268,435,456 bytes) with
InvalidLength. This is a defense-in-depth limit — no legitimate cryptographic input approaches this size, and rejecting oversized buffers early prevents downstream integer overflow or allocation-exhaustion issues in binding languages with unchecked size casts. Exception — streaming chunk functions:soliton_stream_decrypt_chunkandsoliton_stream_decrypt_chunk_atdo not apply a 256 MiB pre-check on thechunkinput — chunk size is bounded structurally bySTREAM_CHUNK_STRIDE(1,048,593 bytes) and the AEAD layer rejects any oversized input. A reimplementer who adds an explicit 256 MiBInvalidLengthguard to streaming chunk functions introduces an observable divergence: the reference implementation returnsAeadFailedfor oversized chunks, notInvalidLength. crypto_versionstring: null vs empty vs non-UTF-8 produce different errors — applies tosoliton_kex_verify_bundle,soliton_kex_initiate,soliton_kex_decode_session_init, andsoliton_kex_receive(the only four CAPI functions that accept acrypto_versionparameter; all other CAPI functions do not take acrypto_versionargument):crypto_versionis passed as a null-terminated C string (const char *), not as a(ptr, len)pair. Three outcomes: (1) A null pointer returnsNullPointer(-13); (2) a non-null pointer to a valid UTF-8 string that is not"lo-crypto-v1"(including an empty string"") returnsUnsupportedCryptoVersion(-16); (3) a non-null pointer whose bytes are not valid UTF-8 returnsInvalidData(-17) — the CAPI'sCStr::from_ptr→to_str()call fails before version comparison can run, and the conversion error maps toInvalidData, notUnsupportedCryptoVersion. A reimplementer who pattern-matches onUnsupportedCryptoVersionto detect all "wrong version" inputs will miss the non-UTF-8 case, which surfaces as the unrelated-seemingInvalidData. This third outcome matters for bindings from runtimes whose string types may not be UTF-8 (Latin-1 in older Java contexts, arbitrary bytes in C char arrays). This distinction matters for bindings that represent "absent" and "empty" differently: some binding languages (PythonNonevs"", Javanullvs"", Swiftnilvs"") have distinct representations for these two cases. The binding's null-to-C mapping must pass a null pointer for "absent" and a pointer-to-null-byte for "empty." Bindings that convertNone/nil/nullto an empty C string (pointer-to-'\0') instead of a null pointer will returnUnsupportedCryptoVersionwhereNullPointeris expected, and vice versa.
Concurrency safety — stateless functions vs. opaque handles: All primitive functions that take no opaque handles are safe to call concurrently from multiple threads without synchronization: soliton_sha3_256, soliton_hmac_sha3_256, soliton_hmac_sha3_256_verify, soliton_hkdf_sha3_256, soliton_aead_encrypt, soliton_aead_decrypt, soliton_xwing_keygen, soliton_xwing_encapsulate, soliton_xwing_decapsulate, soliton_identity_sign, soliton_identity_verify, soliton_verification_phrase, soliton_random_bytes, soliton_argon2id, soliton_zeroize. These functions have no internal mutable state — each call is fully independent. Opaque-handle functions (soliton_ratchet_*, soliton_keyring_*, soliton_stream_*, soliton_kex_*) require exclusive access per-handle; concurrent calls on the same handle are detected by the reentrancy guard and return ConcurrentAccess (-18).
13.3 Buffer Management
typedef struct SolitonBuf {
uint8_t *ptr;
uintptr_t len;
} SolitonBuf;
// Free and zeroize a library-allocated buffer.
// Sets ptr = null, len = 0 after free. Double-free is safe (no-op).
void soliton_buf_free(SolitonBuf *buf);
All library-allocated buffers are zeroized before freeing. The ptr and len fields are zeroed after free, making double-free a safe no-op. The ptr field MUST NOT be modified by the caller: soliton_buf_free passes the stored ptr value directly to free(). If the caller advances ptr (e.g., buf.ptr += n to read from an offset), soliton_buf_free frees the advanced pointer — not the original allocation — causing heap corruption in C or undefined behavior in C++. Use a separate local variable for reading: const uint8_t *p = buf.ptr; while (remaining > 0) { ... p++; remaining--; } — do not modify buf.ptr. The len field may be read but also MUST NOT be modified before the free call; modifying len does not affect soliton_buf_free (which does not use len during deallocation), but doing so breaks the "zeroed after free" invariant and may confuse callers who check len to detect freed state.
All CAPI functions with output buffer parameters zero the output upfront (after null-pointer guard) before any computation, so outputs are always in a defined state even on error. Exception — streaming chunk functions: soliton_stream_decrypt_chunk, soliton_stream_decrypt_chunk_at, and soliton_stream_encrypt_chunk zero the output buffer on error paths only — on success, bytes in the output buffer beyond the written bytes (out_written..out_len) are NOT zeroed. The rationale: the output is ciphertext or plaintext (not secret material requiring zeroization), and the buffer may be as large as CHUNK_SIZE / STREAM_ENCRYPT_MAX (≈1 MiB); zeroing on success would waste cycles per chunk. Reimplementers MUST NOT rely on post-success-write bytes being zero — read out_written to determine the valid range.
Caller-side zeroization: For caller-owned buffers that held secret material (e.g., chain keys copied out of soliton_ratchet_encrypt_first), use soliton_zeroize(ptr, len) — a volatile-write zeroing function guaranteed not to be optimized out by the compiler. Standard C memset may be elided if the buffer is not read afterward. Managed-runtime caveat: In languages with garbage collection (Go, Python, C#, Dart), the runtime may relocate heap objects between the last use of the buffer and the soliton_zeroize call, leaving a copy of the secret material at the old address. Callers in managed runtimes MUST pin the buffer (e.g., GCHandle.Alloc in .NET, pinner in Go 1.21+, ctypes with explicitly allocated C buffers in Python) before writing secrets into it. Alternatively, allocate secret buffers via malloc/calloc (outside the GC's control) and free them after zeroization. Volatile writes to a GC-relocated address zeroize the new location but leave the old location intact — the secret survives in memory with no reference to find it.
13.4 Key Functions
Identity:
soliton_identity_generate(pk_out, sk_out, fingerprint_hex_out)— generate LO composite keypairsoliton_identity_fingerprint(pk, pk_len, out)— compute raw SHA3-256 fingerprintsoliton_identity_sign(sk, sk_len, message, message_len, sig_out)— hybrid signsoliton_identity_verify(pk, pk_len, message, message_len, sig, sig_len)— hybrid verifysoliton_identity_encapsulate(pk, pk_len, ct_out, ss_out)— encapsulate to IK X-Wing component.ss_outreceives a 32-byte shared secret into a caller-allocated buffer. The caller MUST zeroizess_outafter use — usesoliton_zeroize(ss_out, 32).soliton_identity_decapsulate(sk, sk_len, ct, ct_len, ss_out)— decapsulate.ss_outreceives a 32-byte shared secret into a caller-allocated buffer. The caller MUST zeroizess_outafter use — usesoliton_zeroize(ss_out, 32).
Authentication:
soliton_auth_challenge(client_pk, client_pk_len, ct_out, token_out)— server: generate challengesoliton_auth_respond(client_sk, client_sk_len, ct, ct_len, proof_out)— client: generate proofsoliton_auth_verify(expected_token, proof)— server: constant-time verification
LO-KEX:
soliton_kex_verify_bundle(bundle_ik_pk, ..., spk_pub, ..., spk_sig, ...)— verify pre-key bundle. Error codes:BundleVerificationFailed(-5) for all non-structural failures — IK mismatch (bundle_ik_pk ≠ known_ik_pk), invalid SPK signature, orcrypto_version ≠ "lo-crypto-v1". All three collapse to a single error code to prevent iterative oracle probing (an attacker cannot determine which check failed — see §5.3 and §5.5 error-collapsing rationale).InvalidData(-17) on the one structural failure: OPK co-presence violation (opk_pub and opk_id must both be present or both absent) — this check precedes cryptography and is not security-sensitive.InvalidLength(-1) on wrong key/signature sizes. Note:VerificationFailed(-3) is NOT returned by this function — that code is for non-bundle signature operations (identity verification, auth). The collapse toBundleVerificationFailedis intentional; binding authors who pattern-match forVerificationFailedon the bundle verification path will silently miss all bundle-authentication failures.soliton_kex_initiate(alice_ik_pk, ..., bob_ik_pk, ..., bob_spk_pub, ..., ...)— initiate session (returnsSolitonInitiatedSession). Error codes:InvalidLength(-1) if any key or signature has the wrong size.InvalidData(-17) on structural corruption or co-presence violation.BundleVerificationFailed(-5) for all non-structural bundle failures (IK mismatch, unsupported crypto version, invalid SPK signature) —soliton_kex_initiatecallsverify_bundleinternally and the same oracle-collapse applies as forsoliton_kex_verify_bundleabove. SPK signature is re-verified internally even if the caller already calledsoliton_kex_verify_bundle— this is defense-in-depth; binding authors should not attempt to skip the pre-call toverify_bundle. Note:VerificationFailed(-3) andUnsupportedCryptoVersion(-16) are NOT returned by this function — both conditions collapse toBundleVerificationFailed(-5).soliton_kex_receive(bob_ik_pk, ..., bob_ik_sk, ..., alice_ik_pk, ..., ...)— receive session initsoliton_kex_encode_session_init(...)— encode a parsed SessionInit back to canonical bytes (§7.4). Bob's tool, not Alice's: Alice never calls this directly —soliton_kex_initiatehandles encoding internally. Bob callssoliton_kex_encode_session_initafter individually parsing or validating the received fields, to reconstruct Alice's canonical encoding for use in first-message AAD construction. The output must be byte-for-byte identical to Alice's internal encoding; any normalization of individual fields during decode (key clamping, padding removal) that alters re-encoding causes silent first-message AEAD failure.soliton_kex_build_first_message_aad(...)— build first-message AADsoliton_kex_sign_prekey(ik_sk, ..., spk_pub, ..., sig_out)— sign a pre-keysoliton_kex_initiated_session_free(session)— freeSolitonInitiatedSession. Safety model: null-safe (nullsessionis a no-op). Returnsvoid— notint32_tlike opaque-handle free functions (soliton_ratchet_free,soliton_call_keys_free).SolitonInitiatedSessionis a flat#[repr(C)]struct, not an opaque pointer — there is no type-tag field and no type-discriminant check. Callers MUST NOT pass a handle from a different free function (e.g., a ratchet handle) — doing so will zeroize and free incorrect memory without any error or diagnostic.
Ratchet:
soliton_ratchet_init_alice(root_key, ..., chain_key, ..., local_fp, ..., remote_fp, ..., ek_pk, ..., ek_sk, ..., out)— init Alice state; fingerprints follow root_key/chain_key but precede the ephemeral key params (§6.2 parameter order note). Parameter name ischain_keyin the header — see §13.5 for the full name-alias table (epoch_key/chain_key/initial_chain_key).soliton_ratchet_init_bob(root_key, ..., chain_key, ..., local_fp, ..., remote_fp, ..., peer_ek, ..., out)— init Bob state; fingerprints follow root_key/chain_key but precede the ephemeral key params (§6.2 parameter order note). Samechain_keyalias — see §13.5.soliton_ratchet_encrypt(ratchet, plaintext, ..., out)— encrypt (fingerprints are stored in the ratchet state, not passed per call)soliton_ratchet_decrypt(ratchet, ratchet_pk, ..., kem_ct, ..., n, pn, ciphertext, ..., plaintext_out)— decrypt. Passkem_ct = NULLandkem_ct_len = 0when the header contains no KEM ciphertext (same-chain message). Passnandpnexactly as received from the wire header — both are included in AAD regardless of epoch type; a caller who passespn = 0for every message gets AEAD failure whenever the wirepn ≠ 0.soliton_ratchet_encrypt_first(epoch_key, plaintext, ..., aad, ..., payload_out, ratchet_init_key_out)— first messagesoliton_ratchet_decrypt_first(epoch_key, payload, ..., aad, ..., plaintext_out, ratchet_init_key_out)— first message decryptsoliton_ratchet_to_bytes(ratchet, data_out, epoch_out)— serialize state (ownership-consuming: takes*mut *mut SolitonRatchet, nulls the caller's handle on success to prevent post-serialization use;epoch_outreceives the new epoch for anti-rollback tracking, nullable). OnChainExhausted(epoch at u64::MAX — the only counter the CAPIto_byteswrapper visibly checks, becausecan_serialize()pre-filterssend_count/recv_count/prev_send_countat u32::MAX before the CAPI takes ownership; the Rustto_bytesitself checks all four counters),*ratchetis NOT nulled — the handle remains valid. OnConcurrentAccess(-18),*ratchetis also NOT nulled — the handle remains live. OnNullPointer(-13, e.g.,data_outis null),*ratchetis likewise NOT nulled — the call was rejected before ownership transfer began. All three non-success cases that leave the handle intact (NullPointer,ChainExhausted,ConcurrentAccess) are retryable after fixing the caller bug or waiting for the concurrent operation; only a successful return irreversibly transfers and nulls ownership. A binding that frees the handle on any non-zero return code will double-free a live session whenever a null-pointer caller bug triggersNullPointer. Callers who check only for null after failure will lose the handle. Maintainer note: The "NOT nulled onChainExhausted" guarantee depends oncan_serialize()(see §6.8) pre-validating all conditions before the CAPI layer takes ownership of the handle. If a futureto_bytesrefactor introduces a new error condition not covered bycan_serialize(), the handle will be nulled on that error with no recovery path —can_serialize()andto_bytesmust check identical conditions. epoch_out sentinel on error: Whenepoch_outis non-null, the CAPI sets*epoch_out = 0immediately at entry so that error paths never leave stale values from a previous call. Epoch 0 is never a valid serialized epoch (the initialto_bytesproduces epoch 1), so 0 acts as a sentinel meaning "no epoch written." Callers that store*epoch_outas theirmin_epochfor anti-rollback MUST check the return code first and MUST NOT update their storedmin_epochon any error return — storing the sentinel value 0 asmin_epochsilently disables anti-rollback protection for all subsequentfrom_bytes_with_min_epochcalls.soliton_ratchet_from_bytes(data, data_len, out)— deserialize state (deprecated — usefrom_bytes_with_min_epoch; see §6.8). Error codes:InvalidData(-17) on structural blob corruption (guards 1-25, §6.8),ChainExhausted(-15) when the blob encodes epoch == u64::MAX (guard 24 — the session is structurally valid but permanently un-re-serializable; see §12 collapse table).InvalidLength(-1) if the input exceeds the 1 MiB CAPI cap. A binding author who catches onlyInvalidDataand propagates all other errors as "corruption" will silently lose a recoverable serialization-exhausted session.soliton_ratchet_from_bytes_with_min_epoch(data, data_len, min_epoch, out)— deserialize with anti-rollback check (epoch must be > min_epoch). Same error codes asfrom_bytes, plusInvalidDatafor epoch-rollback rejection (guard 12 — indistinguishable from structural corruption at the API level; see §6.8).soliton_ratchet_epoch(ratchet, out)— query current epoch counter non-destructively (sinceto_bytesis ownership-consuming, useepoch()to read the epoch without committing to serialization — e.g., to check consistency with a storedmin_epochbefore callingto_bytes, or to initialize amin_epochstore when migrating existing sessions)soliton_ratchet_reset(ratchet)— reset ratchet state to initial (zeroizes all epoch keys). Returnsint32_t: 0 on success,ConcurrentAccess(-18) if the handle is in use,InvalidData(-17) if the handle's type discriminant is wrong (handle was not created bysoliton_ratchet_init_*; see §13.6 type tagging). OnConcurrentAccess, the state is NOT reset — the caller must retry after the concurrent operation completes. OnInvalidData, the state is also NOT reset — the type-discriminant check fires before any reset logic, so the handle (if it is a valid ratchet handle accidentally passed to the wrong operation) is unmodified and safe to continue using.soliton_ratchet_free(ratchet)— free opaque ratchet. Returnsint32_t: 0 on success,ConcurrentAccess(-18) if in use,InvalidData(-17) if the type discriminant is wrong. Null outer/inner pointer is a safe no-op (returns 0)soliton_encrypted_message_free(msg)— freeSolitonEncryptedMessagebuffer fields (header.ratchet_pk,header.kem_ct,ciphertext). Does NOT free the struct itself —SolitonEncryptedMessageis a caller-owned value type, not an opaque heap handle. After calling this function, the caller is responsible for freeing the struct allocation (e.g.,free(msg)in C). Contrast withsoliton_ratchet_free, which frees the opaque handle allocation.
Call:
soliton_ratchet_derive_call_keys(ratchet, kem_ss, kem_ss_len, call_id, call_id_len, out)— derive call keys.kem_ss_lenMUST be exactly 32 andcall_id_lenMUST be exactly 16; any other value →InvalidLength. These are the only two fixed-size input parameters in the call group with explicit length validation — unlikelocal_fpandremote_fp(taken from ratchet state internally),kem_ssandcall_idare caller-supplied buffers with strict size contracts.soliton_call_keys_send_key(keys, out, out_len)— copy current send key.out_lenmust be exactly 32; any other value returnsInvalidLength. The caller MUST zeroizeoutafter use — usesoliton_zeroize(out, 32). The copied key is live session key material for media encryption.soliton_call_keys_recv_key(keys, out, out_len)— copy current recv key.out_lenmust be exactly 32; any other value returnsInvalidLength. The caller MUST zeroizeoutafter use — usesoliton_zeroize(out, 32). The copied key is live session key material for media encryption.soliton_call_keys_advance(keys)— advance call chain (rekey). ReturnsChainExhausted(-15) after 2²⁴ steps. On exhaustion, all call key material (key_a,key_b,chain_key) is immediately zeroized — the handle is dead:soliton_call_keys_send_keyandsoliton_call_keys_recv_keywill return zeroed material after exhaustion, with no error or diagnostic. The handle is NOT auto-freed onChainExhausted; callers MUST free it viasoliton_call_keys_freeand establish a new call viasoliton_ratchet_derive_call_keys. See §6.12.soliton_call_keys_free(keys)— free opaque call keys (zeroizes). Returnsint32_t: 0 on success,ConcurrentAccess(-18) if in use,InvalidData(-17) if type discriminant wrong. Null outer/inner is safe no-op (returns 0)
Storage:
soliton_storage_encrypt(keyring, plaintext, ..., channel_id, segment_id, compress, out)— encrypt blobsoliton_storage_decrypt(keyring, blob, ..., channel_id, segment_id, out)— decrypt blobsoliton_dm_queue_encrypt(keyring, plaintext, ..., recipient_fp, batch_id, compress, out)— encrypt DM queue blob (§11.4.2 AAD)soliton_dm_queue_decrypt(keyring, blob, ..., recipient_fp, batch_id, out)— decrypt DM queue blobsoliton_keyring_new(key, key_len, version, out)— create keyring (key is fixed 32 bytes). Error codes:NullPointer(-13) ifoutis null;InvalidLength(-1) ifkey_len ≠ 32;UnsupportedVersion(-10) ifversion == 0(version 0 is reserved — §11.1);InvalidData(-17) if the key is all-zero bytes (§11.2 guard — all-zero is an invalid key). Returns 0 on success.soliton_keyring_add_key(keyring, key, key_len, version, make_active)— add key (key is fixed 32 bytes).encrypt_blobalways uses the active version's key.make_active=trueatomically updates the active version to the newly-added key.make_active=falseregisters the key for decryption only (lookup by version byte) — the active version for new encryptions does not change.make_active=falsewith aversionmatching the current active version returnsInvalidData: a caller adding a key with the same version byte as the current active key while passingmake_active=falseintends for the new key material to remain inactive, but the version byte already identifies the active slot — this is an ambiguous / incoherent request (it would silently replace key material for the active version without activating it, making the active version undecryptable for blobs previously encrypted under the old material). The function rejects this withInvalidDatarather than silently updating the key material.soliton_keyring_remove_key(keyring, version)— remove key. Returnsint32_t: 0 if key was present and removed, 0 if key was absent (idempotent),InvalidData(-17) ifversionis the current active version (active key cannot be removed — §10 invariant),UnsupportedVersion(-10) ifversion == 0,NullPointer(-13) if keyring is null,InvalidData(-17) if type discriminant wrong. Design note — both Ok outcomes return 0: The core Rustremove_keyreturnsOk(true)(was present, removed) orOk(false)(was absent). The CAPI collapses both to return code 0 — the distinction is informational and has no security consequence; the idempotency is the externally visible contract. Binding authors who need to distinguish the two cases must track key versions independently or use the Rust API directly.soliton_keyring_free(keyring)— free keyring. Returnsint32_t: 0 on success,ConcurrentAccess(-18) if in use,InvalidData(-17) if type discriminant wrong. Null outer/inner is safe no-op (returns 0)
Streaming AEAD:
soliton_stream_encrypt_init(key, key_len, caller_aad, aad_len, compress, out)— init encryptor (generates random base nonce).key_lenMUST be exactly 32; any other value returnsInvalidLength. Unlikeheader_len(lenient — extra bytes accepted),key_lenis strict — the key is always exactly 32 bytes for XChaCha20-Poly1305.soliton_stream_encrypt_header(enc, out, out_len)— copy 26-byte header into caller-allocated buffer;out_lenMUST be ≥ 26 (lenient: extra buffer space is accepted)soliton_stream_encrypt_chunk(enc, plaintext, ..., is_last, out)— encrypt one chunk;out_lenMUST be ≥STREAM_ENCRYPT_MAX(1,048,849 bytes) — returnsInvalidLengthfor smaller buffers (parallel to theout_len < STREAM_CHUNK_SIZE → InvalidLengthrule for decrypt chunk)soliton_stream_encrypt_chunk_at(enc: *const, index, plaintext, ..., is_last, out)— encrypt at explicit index (stateless, random-access); uses*const SolitonStreamEncryptor(not*mut) to reflect the&selfRust contract — see §15.11 for the*constcaveat. Sameout_len≥STREAM_ENCRYPT_MAXrequirement as the sequential variant.indexMUST be unique per call — calling with the sameindexand different plaintexts produces nonce reuse (§15.12). Does not advancenext_index. Not interchangeable with the sequential variant; see §15.11 for mixed-mode use. Absent fromsoliton.h: this function is implemented and exported (#[unsafe(no_mangle)]) but has no declaration in the C header — its decrypt counterpartsoliton_stream_decrypt_chunk_atis declared in the header. Binding authors (C, C++, Go cgo, C#, Dart) must supply a manualexterndeclaration matching the signature above until the header is updated.soliton_stream_encrypt_is_finalized(enc, out: *mut bool)— write finalized state tooutsoliton_stream_encrypt_free(enc)— free encryptor (zeroizes key). Returnsint32_t: 0 on success,NullPointer(-13) if outer pointer null, 0 (safe no-op) if inner pointer null (null inner pointer means the handle was already freed or never initialized — matches the double-free behavior ofsoliton_ratchet_free/soliton_keyring_free; does NOT returnNullPointerfor inner-null),ConcurrentAccess(-18) if in use,InvalidData(-17) if type discriminant wrong
soliton_stream_encrypt_chunk output buffer — only out_written bytes are valid: On a successful return from soliton_stream_encrypt_chunk, only the first *out_written bytes of out contain ciphertext. The output buffer must be at least STREAM_ENCRYPT_MAX (1,048,849 bytes) to accommodate any valid chunk, but a non-final uncompressed chunk writes exactly CHUNK_SIZE + CHUNK_OVERHEAD = 1,048,593 bytes, leaving the remaining 256 bytes of a minimum-sized buffer uninitialized. A binding author who copies out[0..STREAM_ENCRYPT_MAX] to a transport (instead of out[0..*out_written]) transmits up to 256 bytes of heap content alongside the ciphertext — ciphertext is not secret, but the heap bytes may contain earlier key material or other sensitive data from the process heap. Always use *out_written to determine the valid range. This mirrors the behavior documented for soliton_stream_decrypt_chunk and soliton_stream_decrypt_chunk_at.
No soliton_stream_encrypt_next_index function: After encrypt_chunk(is_last=true) succeeds, the chunk count equals the encryptor's internal next_index — but this is not exposed via CAPI. §15.12 describes how to track chunk count: callers must count encrypt_chunk calls manually, or use is_finalized() to confirm the stream is complete. The decrypt-side soliton_stream_decrypt_expected_index has no symmetric encrypt-side counterpart — this asymmetry is intentional.
soliton_stream_decrypt_init(key, key_len, header, header_len, caller_aad, aad_len, out)— init decryptor from header;key_lenMUST be exactly 32 (strict: any other length returnsInvalidLength, same as encrypt_init);header_lenMUST be exactly 26 (strict: any other length returnsInvalidLength)soliton_stream_decrypt_chunk(dec, chunk, chunk_len, out, out_len, out_written, is_last: *mut bool)— decrypt sequential chunk;out_lenMUST be ≥STREAM_CHUNK_SIZE(1,048,576 bytes) — returnsInvalidLengthfor smaller buffers (see note below);is_lastis a required non-null out-parameter — returnsNullPointerif nullsoliton_stream_decrypt_chunk_at(dec, index, chunk, chunk_len, out, out_len, out_written, is_last: *mut bool)— decrypt at explicit index (stateless); sameout_len≥STREAM_CHUNK_SIZErequirement as above;is_lastis a required non-null out-parameter — returnsNullPointerif nullsoliton_stream_decrypt_is_finalized(dec, out: *mut bool)— write finalized state tooutsoliton_stream_decrypt_expected_index(dec, out: *mut u64)— write next expected sequential index tooutsoliton_stream_decrypt_free(dec)— free decryptor (zeroizes key). Returnsint32_t: 0 on success,NullPointer(-13) if outer pointer null, 0 (safe no-op) if inner pointer null (null inner pointer means already-freed or never-initialized — does NOT returnNullPointerfor inner-null, consistent withsoliton_ratchet_free/soliton_keyring_free),ConcurrentAccess(-18) if in use,InvalidData(-17) if type discriminant wrong
SOLITON_STREAM_ENCRYPT_MAX and SOLITON_STREAM_CHUNK_SIZE are NOT defined as #define constants in soliton.h: The header references these names in documentation comments but does not provide #define or constexpr entries. Binding authors who write out_len = SOLITON_STREAM_ENCRYPT_MAX get a compile error. The values must be embedded as integer literals in bindings: STREAM_ENCRYPT_MAX = 1,048,849 (encrypt output buffer, see Appendix A) and STREAM_CHUNK_SIZE = 1,048,576 (decrypt output buffer, see Appendix A). Language-idiomatic constant definitions are recommended:
// C/C++ — add to binding wrapper or generated header
#define SOLITON_STREAM_ENCRYPT_MAX 1048849UL
#define SOLITON_STREAM_CHUNK_SIZE 1048576UL
These values are stable and will not change without a major version bump.
Streaming decrypt output buffer minimum — STREAM_CHUNK_SIZE (1,048,576 bytes): Both soliton_stream_decrypt_chunk and soliton_stream_decrypt_chunk_at require the output buffer to be at least STREAM_CHUNK_SIZE bytes regardless of the expected plaintext size. This is because the buffer size cannot be known before decryption completes (for compressed streams, the decompressed size is variable and determined post-AEAD; for uncompressed streams, the plaintext size equals the ciphertext minus the 16-byte AEAD tag, which requires parsing the ciphertext first). The library therefore mandates a worst-case buffer that can hold any valid decrypted chunk. This is asymmetric with the encrypt side: the encrypt output buffer uses STREAM_ENCRYPT_MAX (1,048,849 bytes), which is larger than STREAM_CHUNK_SIZE to accommodate compression overhead and the tag_byte. The decrypt minimum is the raw STREAM_CHUNK_SIZE because decrypt outputs plaintext (no tag_byte, no compression overhead). Binding authors who size the output buffer to the expected plaintext for a small final chunk (e.g., a 100-byte final chunk with a 100-byte output buffer) will receive InvalidLength with no diagnostic in the error message indicating that buffer size is the cause. See Appendix A for the constant value.
Streaming header buffer size asymmetry: soliton_stream_encrypt_header accepts any out_len ≥ 26 (lenient — a 32-byte output buffer is fine). soliton_stream_decrypt_init requires header_len == 26 exactly (strict — any other length returns InvalidLength). This asymmetry is intentional: the encryptor writes into a caller-owned buffer and the caller controls the buffer size; the decryptor parses an input buffer where any size other than exactly 26 indicates a framing error. A caller who stores the 26-byte header in a 32-byte buffer can encrypt successfully but must pass exactly header_len = 26 to decrypt_init — passing the full buffer length (32) returns InvalidLength.
Primitives:
soliton_hmac_sha3_256(key, key_len, data, data_len, out, out_len)— HMAC-SHA3-256.out_lenmust be exactly 32 (the HMAC-SHA3-256 output size); any other value returnsInvalidLength. Unlike most output-length parameters in the CAPI (which express a caller-allocated buffer size),out_lenhere is a strict size-check: the function does not produce a variable-length output.soliton_hkdf_sha3_256(salt, salt_len, ikm, ikm_len, info, info_len, out, out_len)— HKDF-SHA3-256.out_lenconstraint: must be in the range 1-8160 bytes. The upper bound is the RFC 5869 §2.3 HKDF-Expand maximum: 255 × HashLen = 255 × 32 = 8160 bytes for SHA3-256. A zeroout_lenorout_len > 8160returnsInvalidLength.soliton_sha3_256(data, data_len, out, out_len)— SHA3-256.out_lenmust be exactly 32; any other value returnsInvalidLength.soliton_xwing_keygen(pk_out, sk_out)— X-Wing key generationsoliton_xwing_encapsulate(pk, pk_len, ct_out, ss_out)— X-Wing encapsulate.ss_outreceives a 32-byte shared secret into a caller-allocated buffer. The caller MUST zeroizess_outafter use — usesoliton_zeroize(ss_out, 32).soliton_xwing_decapsulate(sk, sk_len, ct, ct_len, ss_out)— X-Wing decapsulate.ss_outreceives a 32-byte shared secret into a caller-allocated buffer. The caller MUST zeroizess_outafter use — usesoliton_zeroize(ss_out, 32).soliton_aead_encrypt(key, key_len, nonce, nonce_len, plaintext, ..., aad, ..., out)— raw XChaCha20-Poly1305 encrypt.key_lenMUST be exactly 32 (AES-style key mismatch: XChaCha20-Poly1305 uses a 256-bit key); any other value returnsInvalidLength.nonce_lenMUST be exactly 24 — XChaCha20 uses a 192-bit nonce; passing a 12-byte ChaCha20 nonce returnsInvalidLength. This is a common caller error when migrating fromchacha20poly1305(12-byte nonce) toxchacha20poly1305(24-byte nonce).soliton_aead_decrypt(key, key_len, nonce, nonce_len, ciphertext, ..., aad, ..., out)— raw XChaCha20-Poly1305 decrypt. Same key and nonce length constraints assoliton_aead_encrypt:key_lenmust be 32 andnonce_lenmust be 24; any other value returnsInvalidLength.soliton_hmac_sha3_256_verify(tag_a, tag_a_len, tag_b, tag_b_len)— constant-time 32-byte tag comparison. Returns 0 if equal,VerificationFailed(-3) if tags differ,InvalidLength(-1) if either length ≠ 32. Constant-time is a security requirement — comparison time must be independent of tag contents to prevent timing attacks on authentication tokens (§4). Do NOT substitutememcmp()or any early-exit comparison.soliton_argon2id(password, ..., salt, ..., m_cost, t_cost, p_cost, out, out_len)— Argon2id KDF (§10.6).out_lenconstraint: 1-4096 bytes; zero or> 4096returnsInvalidLength(see §10.6 cap rationale).soliton_verification_phrase(pk_a, pk_a_len, pk_b, pk_b_len, out)— verification phrasesoliton_random_bytes(buf, len)— fillbufwithlencryptographically random bytes from the OS CSPRNG. Output cap:lenmust be ≤ 256 MiB (268,435,456 bytes) — requests exceeding this returnInvalidLength. CSPRNG failure aborts: like keygen and encapsulation (§13.2),soliton_random_bytesaborts the process on OS entropy failure rather than returning an error code. Binding authors MUST NOT expect an error return on CSPRNG failure for this function.soliton_zeroize(ptr, len)— volatile-write zeroing (guaranteed not optimized out — use for caller-owned secret buffers). Null-safe and zero-length-safe: ifptris NULL orlen == 0, the function is a silent no-op (returns immediately without error and without performing any memory write). This diverges from the general §13.2 convention where null pointers returnNullPointer(-13) —soliton_zeroizedoes NOT return an error code for null input. Callers relying onsoliton_zeroizeto confirm that a buffer was zeroed MUST verifyptr != NULL && len > 0before calling; the silent no-op means a null-check failure is invisible at the call site.soliton_zeroizehas no return value — it returnsvoid(C) /()(Rust). Unlike all other CAPI functions, there is noint32_treturn code to check; the function either performs the volatile writes or silently does nothing.soliton_version()— return version string as*const c_char. Static lifetime — do NOT free: The returned pointer is embedded in the library binary (a'staticstring slice in Rust, exposed as a C string literal). It is valid for the lifetime of the process, never null, and MUST NOT be passed tofree()orsoliton_buf_free(). Callingfree()on a static pointer is undefined behavior (heap corruption). Binding authors who follow the "every library allocation must be freed" convention from §13.2 must add an exception forsoliton_version(). This function is the sole CAPI function that returns a raw C string pointer rather than aSolitonBuf; all other variable-length string outputs useSolitonBufand are heap-allocated. The pointer remains valid as long as the library is loaded.
KEX (additional):
soliton_kex_decode_session_init(data, data_len, out)— decode SessionInit from bytes. Input cap: 64 KiB (65,536 bytes). Inputs exceeding 64 KiB returnInvalidLength. This is tighter than the general 256 MiB CAPI cap (§13.2) — the maximum valid SessionInit is 4,669 bytes (with OPK; §7.4), so 64 KiB is a safe conservative bound that prevents allocation-exhaustion from oversized buffers.soliton_decoded_session_init_free(session)— free decoded SessionInit: frees thecrypto_versionSolitonBuf. No zeroization is performed —SolitonDecodedSessionInitcontains no secret material (§13.6). Nullsessionis a safe no-op. Must be called on every successfulsoliton_kex_decode_session_initoutput.soliton_kex_received_session_free(session)— free received session
soliton_kex_build_first_message_aad input cap: This function constructs the first-message AAD from a SolitonInitiatedSession and returns it as a SolitonBuf. It applies an 8 KiB (8,192 bytes) internal cap on the combined size of the SessionInit encoding and ancillary fields. Inputs exceeding 8 KiB return InvalidLength. In practice the SessionInit encoding is at most 4,669 bytes (§7.4), so this cap is never reached with well-formed inputs. Binding authors who synthesize oversized mock SolitonInitiatedSession structs for testing may encounter this limit.
opk_sk co-presence error codes at the CAPI level: The two directions of OPK co-presence violation produce different error codes at the CAPI level. (1) When ct_opk is non-null (OPK ciphertext present) but opk_sk is null (the OPK secret key pointer is null): the CAPI's null-pointer guard for opk_sk fires first and returns NullPointer (-13), before the co-presence check runs. (2) When ct_opk is null (no OPK ciphertext) but opk_sk is non-null (a secret key pointer was passed): the co-presence check fires and returns InvalidData (-17), because the OPK secret key was supplied for an absent OPK ciphertext. Binding authors pattern-matching on errors from soliton_kex_receive MUST handle both: NullPointer for "OPK ciphertext present, OPK secret key missing" and InvalidData for "OPK ciphertext absent, OPK secret key present."
opk_id co-presence constraint: When ct_opk is null (no OPK ciphertext), opk_id MUST be 0. Passing a non-zero opk_id with a null ct_opk returns InvalidData. This constraint is enforced by soliton_kex_receive and soliton_kex_decode_session_init. The opk_id field is meaningful only when ct_opk is present; a non-zero opk_id with absent ct_opk indicates a malformed SessionInit (the OPK key lookup would use the non-zero ID to look up an OPK that the protocol says is not being used). A reimplementer who initializes opk_id to a non-zero default when building a no-OPK SessionInit will receive InvalidData on the receiving side. opk_id = 0 is a valid OPK ID when has_opk = true / ct_opk is present: A server can assign OPK ID 0 to the first uploaded one-time pre-key. When a SessionInit arrives with has_opk = 0x01 (or ct_opk non-null) and opk_id = 0, this means OPK ID 0 was used — has_opk is the sole authority for whether an OPK was included. opk_id = 0 does NOT act as a sentinel for "no OPK present" in the case where has_opk = true. A reimplementer who treats opk_id == 0 as "no OPK" and ignores has_opk will discard valid SessionInits that used OPK ID 0, silently ignoring the OPK ciphertext and producing wrong decapsulation output. In SolitonDecodedSessionInit, has_opk is the canonical field to check; opk_id must only be used when has_opk == 1.
13.5 Key Usage Order for Session Initiation
The epoch key flows through several steps and the right value must be passed to each:
// Alice (initiator):
soliton_kex_initiate(...) → SolitonInitiatedSession { initial_epoch_key, ... }
soliton_ratchet_encrypt_first(initial_epoch_key, plaintext, aad, ...) → (payload, ratchet_init_key)
soliton_ratchet_init_alice(root_key, ratchet_init_key, ek_pk, ek_sk, ...)
// Bob (responder):
soliton_kex_receive(...) → (root_key, initial_epoch_key, peer_ek)
soliton_ratchet_decrypt_first(initial_epoch_key, payload, aad, ...) → (plaintext, ratchet_init_key)
soliton_ratchet_init_bob(root_key, ratchet_init_key, peer_ek, ...)
aad parameter for encrypt_first / decrypt_first: The aad parameter is the first-message AAD constructed by build_first_message_aad / soliton_kex_build_first_message_aad. Its value is (§7.3 / §5.4 Step 7): "lo-dm-v1" || sender_fingerprint_raw || recipient_fingerprint_raw || encode_session_init(session_init). Both Alice and Bob must pass byte-for-byte identical aad bytes; any divergence (wrong label, wrong fingerprint order, non-canonical encode_session_init output) produces AeadFailed on Bob's decrypt_first with no diagnostic pointing to the AAD. A reimplementer reading §13.5 in isolation who constructs aad from the per-function parameter description only will not find the required content — it must be sourced from §5.4 Step 7 (Alice's side) and §5.5 Step 6 (Bob's side). The easiest correct implementation calls soliton_kex_build_first_message_aad (§13.4) to produce this value rather than constructing it manually.
encrypt_first_message / decrypt_first_message are pre-RatchetState standalone operations: These functions take an initial_epoch_key parameter directly — they do NOT require or use a SolitonRatchet handle. They are stateless AEAD operations called before the ratchet is initialized (ratchet_init_alice / ratchet_init_bob). A reimplementer who constructs a SolitonRatchet first and then tries to pass it to the first-message functions has misread the call sequence — the first-message functions consume the initial epoch key and return ratchet_init_key, which is then passed to ratchet init.
ratchet_init_key is the epoch key returned unchanged by encrypt_first_message / decrypt_first_message — it is the input initial_epoch_key passed through (counter-mode does not advance the epoch key). It is passed to ratchet_init_alice / ratchet_init_bob as the initial epoch key. It is not a separate derived value.
Name equivalence (epoch key): The same 32-byte value (session_key[32..64] from §5.4 Step 4) appears under four names across the spec, Rust API, and CAPI: epoch_key (§5.4 protocol pseudocode), initial_epoch_key (CAPI SolitonInitiatedSession / soliton_kex_receive output in the §13.5 pseudocode), initial_chain_key (Rust InitiatedSession::take_initial_chain_key() — historical name from the pre-counter-mode chain design), and ratchet_init_key (CAPI return from encrypt_first / decrypt_first). All four are the same value at different points in the key flow. SolitonReceivedSession struct field name: In the SolitonReceivedSession C struct (§13.6), Bob's copy of this value is named chain_key — not initial_epoch_key. The §13.5 pseudocode uses initial_epoch_key as the return-value label for soliton_kex_receive; the §13.6 struct layout names the same field chain_key. A binding author laying out SolitonReceivedSession manually must use the field name chain_key, not initial_epoch_key.
Name equivalence (ephemeral public key): Alice's ephemeral X-Wing public key (EK_pub, 1216 bytes) also appears under three names: sender_ek (the SessionInit struct field transmitted in §5.4 Step 5), ek_pk (the SolitonInitiatedSession field returned by soliton_kex_initiate and stored in Alice's ratchet handle via soliton_ratchet_init_alice), and send_ratchet_pk (the RatchetState field after init_alice — Alice's initial send ratchet public key, which Bob will encapsulate to on his first send). Getting this mapping wrong means Bob's first KEM ratchet step encapsulates to a different key than Alice expects — the resulting kem_ss diverges, the new epoch key diverges, and every subsequent message fails with AeadFailed with no diagnostic pointing to the mismatched key.
Passing initial_epoch_key directly to ratchet init (skipping the first-message step) produces no immediate error — AEAD encryption succeeds with any 32-byte key — but decryption at the remote end will fail.
soliton_kex_receive wrong-key-ID silent failure: If a recognized spk_id is paired with the wrong secret key (e.g., storage corruption maps a valid ID to different key material), soliton_kex_receive succeeds and returns a valid-looking SolitonReceivedSession — but X-Wing implicit rejection produces a pseudorandom ss_spk, so root_key and initial_epoch_key diverge from Alice's. The error surfaces only when decrypt_first_message fails with AeadFailed, with no diagnostic distinguishing this from ciphertext tampering or transport corruption. This is the same category of silent failure as passing initial_epoch_key directly to ratchet init. Bob's spk_id → sk mapping MUST be verified for integrity independently (e.g., by storing a fingerprint of the SPK public key alongside the private key and checking it before decapsulation) — see §5.5 Step 4.
Single-use key extraction: InitiatedSession and ReceivedSession enforce single-use extraction of root_key and initial_epoch_key. The first call to take_root_key() / take_initial_epoch_key() returns the value and replaces the internal copy with zeros. A second call returns all-zeros, which ratchet init rejects (all-zero root_key is invalid). Reimplementers providing accessor methods (get_root_key()) instead of consuming methods risk accidental key reuse — extracting the same root key twice and initializing two ratchets produces two sessions with identical state, causing nonce reuse on the first message.
ek_sk is also single-use: The ek_sk field (X-Wing ephemeral secret key, 2432 bytes) in SolitonInitiatedSession MUST be passed to exactly one soliton_ratchet_init_alice call. ek_sk is the X-Wing decapsulation key that Alice will use to decapsulate Bob's first KEM ratchet ciphertext — passing it to two init_alice calls creates two ratchet instances with identical send_ratchet_sk. When Bob sends his first ratchet message, he encapsulates to Alice's ek_pk once; only one of the two Alice instances can derive the correct epoch key from the resulting KEM ciphertext. The other instance has the same send_ratchet_sk but decapsulates against a mismatched ciphertext, producing a wrong kem_ss, a wrong recv_epoch_key, and silent AeadFailed on the first message with no diagnostic pointing to the duplicated ek_sk. Unlike root_key and initial_epoch_key, ek_sk is not enforced as single-use by a consuming wrapper at the CAPI level (it is passed as a *const SolitonBuf raw pointer); callers MUST NOT reuse it. After soliton_ratchet_init_alice returns, the ek_sk buffer should be freed via soliton_kex_initiated_session_free — do not pass it to any further init_alice calls.
13.6 Opaque Structs
SolitonRatchet, SolitonKeyRing, SolitonCallKeys, SolitonStreamEncryptor, and SolitonStreamDecryptor are heap-allocated opaque structs. Their internal layout is not part of the ABI. They must be freed with soliton_ratchet_free, soliton_keyring_free, soliton_call_keys_free, soliton_stream_encrypt_free, and soliton_stream_decrypt_free respectively.
SolitonInitiatedSession is a flat C struct with both inline fields (zeroed by soliton_kex_initiated_session_free) and SolitonBuf fields. The ek_sk field must be freed via soliton_kex_initiated_session_free — do NOT call soliton_buf_free on ek_sk directly. soliton_buf_free frees the heap allocation and nulls the SolitonBuf fields, but the inline root_key and initial_chain_key arrays (32 bytes each, embedded directly in the struct, not SolitonBuf fields) are left unzeroized. The dedicated free function zeroizes both inline arrays and then frees the SolitonBuf fields. Calling soliton_buf_free on ek_sk followed by the dedicated free is safe (the null-after-free guarantee makes the second free of ek_sk a no-op), but calling only soliton_buf_free leaks 64 bytes of secret material. GC language hazard: SolitonInitiatedSession contains inline root_key and initial_chain_key (32 bytes each) — secret material embedded directly in the struct. In GC languages (C#, Go, Python), the GC may relocate (compact) a managed-heap struct, leaving unzeroized copies of these keys at the old address. Binding authors MUST allocate this struct in pinned/unmanaged memory (Marshal.AllocHGlobal, C.malloc, ctypes.create_string_buffer) and call soliton_kex_initiated_session_free immediately after extracting both keys to minimize the pinned lifetime.
GC language hazard — SolitonReceivedSession: The identical hazard applies to SolitonReceivedSession (Bob's side). SolitonReceivedSession contains inline root_key ([u8; 32]) and chain_key ([u8; 32]) — secret material embedded directly in the struct alongside SolitonBuf fields for peer_ek. GC relocation at any point between soliton_kex_receive returning and soliton_kex_received_session_free executing leaves unzeroized copies at the old address. Binding authors MUST apply the same pinned/unmanaged-memory allocation to SolitonReceivedSession as to SolitonInitiatedSession. The mitigation pattern is: allocate SolitonReceivedSession in pinned memory → call soliton_kex_receive → extract root_key and chain_key into pinned buffers → call soliton_kex_received_session_free → unpin. This struct is Bob's counterpart to Alice's SolitonInitiatedSession and carries the same category of secret material.
Alignment padding in flat structs: SolitonInitiatedSession has two implicit padding gaps that binding authors laying out the struct manually (Go struct, C# StructLayout, Python ctypes.Structure) MUST include explicitly. (1) spk_id → ct_opk (4-byte gap): spk_id (uint32_t, 4 bytes) ends at offset 212, but ct_opk (SolitonBuf) requires 8-byte pointer alignment — the next 8-aligned boundary is offset 216, so 4 bytes of implicit padding appear at offsets 212-215. A binding author who places ct_opk at offset 212 corrupts all subsequent fields. (2) has_opk → sender_sig (3-byte gap): has_opk (uint8_t) is followed by 3 bytes of implicit alignment padding before the next pointer-aligned SolitonBuf field. The generated soliton.h header handles both gaps automatically via C's natural alignment rules. The same 3-byte has_opk pattern applies to SolitonDecodedSessionInit's has_opk field (3 bytes padding before the next 4-byte-aligned field).
SolitonDecodedSessionInit contains no secret material — no zeroization required: All fields are wire-transmitted public or semi-public values (ciphertexts, public keys, fingerprints, version string). None require zeroization or privileged memory treatment. Callers MAY discard this struct normally after use — free() in C, garbage collection in managed languages, stack deallocation in Rust. Contrast with SolitonInitiatedSession and SolitonReceivedSession, which contain secret key material (root_key, epoch keys) derived from KEM operations and MUST be freed exclusively via soliton_kex_initiated_session_free / soliton_kex_received_session_free, which zeroize their contents before deallocation. SolitonDecodedSessionInit does not have and does not need a zeroizing free function.
SolitonDecodedSessionInit is large (4,672 bytes on LP64) — avoid stack allocation in constrained environments: This struct contains the full decoded fields of a SessionInit including ct_ik (1,120 bytes), ct_spk (1,120 bytes), ct_opk (1,120 bytes), sender_ek (1,216 bytes), and one SolitonBuf field (crypto_version, 16 bytes on LP64: ptr + len). The exact #[repr(C)] size on LP64 is 4,672 bytes: crypto_version(16) + sender_fp(32) + recipient_fp(32) + sender_ek(1216) + ct_ik(1120) + ct_spk(1120) + spk_id(4) + has_opk(1) + ct_opk(1120) + 3 bytes alignment padding + opk_id(4) + 4 bytes trailing struct padding to align to 8 bytes = 4,672. Binding authors doing manual struct layout (Go struct, C# StructLayout, Python ctypes.Structure) must include both the 3-byte padding before opk_id and the 4-byte trailing padding. On Go goroutines (initial stack 8 KiB, fragmented by other locals) and .NET async state machines (stack budget shared with awaiter frames), placing this struct on the stack risks non-deterministic stack overflow. Binding authors MUST heap-allocate this struct: C.malloc in Go, Marshal.AllocHGlobal in .NET, ctypes.create_string_buffer(ctypes.sizeof(...)) in Python, or equivalent. In Rust, the core library's SolitonDecodedSessionInit is behind a Box<>; C/Go/Python bindings must ensure the same. A binding that allocates this struct on the frame stack passes tests on machines with ample stack space but crashes non-deterministically in production under deep call chains.
SolitonDecodedSessionInit.crypto_version SolitonBuf length includes null terminator: The crypto_version field of SolitonDecodedSessionInit is a SolitonBuf whose len is 13 for the current version — 12 bytes for the string "lo-crypto-v1" plus one trailing null byte (\0). The null byte is included to make the buffer directly usable as a C string without an additional copy. Binding authors who read len and compare it to 12 (the character count of "lo-crypto-v1") will find len == 13 and may incorrectly conclude the version string is malformed. The correct validation pattern is: buf.len == 13 && buf.ptr[0..12] == b"lo-crypto-v1" && buf.ptr[12] == 0. Do NOT pass buf.len as the length of a cryptographic comparison (e.g., to a constant-time compare function) expecting 12 — the extra null byte would cause a mismatch against a 12-byte reference string.
SolitonRatchetHeader and SolitonEncryptedMessage layouts (flat value types — binding authors must lay out manually): These are #[repr(C)] flat structs returned from soliton_ratchet_encrypt and passed to soliton_ratchet_decrypt. Unlike the opaque handle types above, binding authors in Go, C#, and Python must lay out these structs explicitly.
SolitonRatchetHeader (40 bytes on LP64):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 16 | ratchet_pk |
SolitonBuf — sender's ratchet public key (library-allocated; ptr + len, each 8 bytes on LP64) |
| 16 | 16 | kem_ct |
SolitonBuf — KEM ciphertext, if present; ptr is null and len is 0 if absent (same-epoch message) |
| 32 | 4 | n |
uint32_t — message number within current send chain |
| 36 | 4 | pn |
uint32_t — length of the previous send chain |
SolitonEncryptedMessage (56 bytes on LP64):
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 40 | header |
SolitonRatchetHeader (inline, not a pointer) |
| 40 | 16 | ciphertext |
SolitonBuf — AEAD-encrypted message (library-allocated) |
kem_ct.ptr == null (null pointer, len == 0) signals absence of a KEM ciphertext — do NOT use an all-zero SolitonBuf as the absent sentinel. On success, soliton_ratchet_encrypt zeroes the entire SolitonEncryptedMessage output before writing, so the null-ptr convention applies on success paths. On error, the output is also zeroed (making soliton_encrypted_message_free safe to call on error paths — it is a no-op on zero-initialized structs). Binding authors MUST pass kem_ct.ptr as NULL and kem_ct.len as 0 to soliton_ratchet_decrypt when the header contains no KEM ciphertext.
Type-tagging: Each opaque handle type embeds a 4-byte magic discriminant as its first field. The _free functions validate this discriminant before operating on the pointer. Passing a handle to the wrong free function (e.g., soliton_ratchet_free on a SolitonKeyRing*) is detected and returns InvalidData rather than corrupting memory. The discriminant values are internal and not part of the ABI.
soliton.h OWNERSHIP comment says cross-type free is "undefined behavior" — Specification.md is normative: The generated soliton.h header may carry an OWNERSHIP comment stating that passing the wrong handle type to a _free function is "undefined behavior." This contradicts §13.6's normative claim that the type discriminant check catches this and returns InvalidData. Specification.md is normative; the header comment is documentation only. Binding authors reading soliton.h who see "undefined behavior" and add their own UB-protection wrappers (null-checking the outer pointer, refusing to call _free when uncertain of the handle type) may inadvertently mask the InvalidData return code. The correct model: the discriminant check is implemented; cross-type free returns InvalidData (-17); no memory is corrupted; binding wrappers should propagate InvalidData as a type-mismatch error, not treat cross-type free as safe to elide.
Pointer aliasing: Opaque handles must not be aliased. Copying a handle pointer via memcpy and then using both copies produces undefined behavior — specifically, two encrypt calls on aliased SolitonRatchet handles will use the same nonce, causing catastrophic AEAD nonce reuse. The CAPI does not enforce single-ownership at the API level; this is a caller obligation. If a binding language needs to share a handle across threads, it must serialize access (e.g., mutex).
14. Security Analysis
14.1 Compromised Community Server
Impact: Reads group plaintext, observes connected users, could modify or inject messages, present fake keys. Mitigations: Group chat visibility accepted by design (§11 — community storage is channel-keyed, not user-keyed). DMs E2E encrypted (§5-§6). Fake key presentation mitigated by verification phrases (§9) + key pinning + key change warnings.
14.2 Compromised DM Relay
Impact: Metadata (sender/recipient/timing), stored ciphertext, could substitute pre-keys. Mitigations: Content E2E encrypted (§5-§6). Pre-key substitution → hybrid signature verification fails (requires breaking both Ed25519 and ML-DSA; §3.2, §5.3).
14.3 Harvest-Now-Decrypt-Later
Impact: Recorded ciphertext held for future quantum computer. Mitigations: X-Wing ML-KEM-768 protects session keys (§8). ML-DSA-65 protects signature integrity (§3).
What a CRQC breaking X25519 alone cannot do: X-Wing combines X25519 and ML-KEM-768 with a SHA3-256 combiner (§8). Breaking X25519 yields ss_X but not ss_M. The session key is SHA3-256(ss_M || ss_X || ct_X || pk_X || label) — an attacker who knows ss_X but not ss_M cannot recover the session key. A classical quantum computer (CRQC) capable of Shor's algorithm against X25519 gains nothing unless ML-KEM-768 is simultaneously broken. The harvest-now-decrypt-later threat is neutralized for session keys as long as ML-KEM-768 remains secure. What is at risk: pre-key bundle signatures (if ML-DSA-65 is broken) and the X25519 component of initial session key material, which contributes to the hybrid combiner's IND-CCA2 security claim but not to security when ML-KEM-768 is intact.
14.4 Identity Key Compromise
Can: Impersonate user, sign fake pre-keys (§5.3), authenticate as user (§4). Cannot: Decrypt past or current sessions with IK alone (also requires SPK private key — see §5.6). Decrypt current sessions (needs ratchet keys, §6). Impersonate others to compromised user. Recovery: New identity keypair. Contacts re-verify phrases (§9).
IK + SPK capability window is bounded by the SPK retention policy: A combined IK-and-SPK compromise recovers session keys only while sk_SPK is retained. SPKs are rotated every 7 days and the private key is deleted 30 days after rotation (§10.2, Appendix B). After sk_SPK deletion, even combined IK + SPK capability cannot recover that session's key — ss_spk is no longer computable. The attacker's window is at most 37 days from SPK generation (7-day rotation interval + 30-day retention window). Sessions established more than 37 days before the compromise with no SPK re-use are retrospectively safe.
14.5 First Contact (TOFU)
On first contact without prior keys, mutual auth not guaranteed. Same as Signal/SSH. See §5.6.
Verification phrase birthday resistance (~2^45): Verification phrases (§9) provide a partial mitigation for TOFU key substitution, but their birthday resistance is limited to approximately 2^45 SHA3-256 operations (§9.4). A well-resourced attacker who controls key generation at scale can generate ~2^45 identity keys and, by the birthday paradox, find two that produce the same verification phrase when paired with a given victim key — substituting the colliding key passes the out-of-band check. For most threat models this is out of reach, but the limitation is relevant for environments with state-level adversaries. Applications with high-threat requirements SHOULD supplement verification phrase comparison with full 32-byte fingerprint comparison (64 lowercase hex characters, §2.1), which provides ~256-bit second-preimage resistance against key substitution. See §9.4 for the full collision analysis.
14.6 Ratchet State Desynchronization
Counter-mode derivation eliminates stateful chain advancement, the primary historical source of desynchronization. Session reset (§6.10) recovers at cost of in-flight messages.
14.7 Header Tampering
All header fields bound into AEAD AAD (§7.3-7.4). Tampering → AEAD failure. Prevents state poisoning.
14.8 Storage Blob Relocation
Channel and segment IDs in storage AAD (§11.4). Blobs cannot be moved.
14.8a Ratchet State Blob Substitution
Impact: An attacker with write access to persisted ratchet state blobs can substitute an older blob (replay attack) or a blob from a different session (session confusion), potentially recovering old messages or inducing key reuse.
Substitution of an older blob: Reloading a stale ratchet blob rolls back send_count, causing nonce reuse: the next encrypted message reuses a counter that was already used in the current epoch, producing a ciphertext under the same (key, nonce) pair as a previously sent message. AEAD nonce reuse with the same key recovers the XOR of the two plaintexts — a catastrophic confidentiality failure. Mitigations: per §6.8 Caller Obligation 2, callers MUST store the last-known epoch (new_epoch - 1) and pass it to from_bytes_with_min_epoch on reload. Any blob with epoch ≤ min_epoch is rejected with InvalidData. Callers who use from_bytes (no min_epoch) instead of from_bytes_with_min_epoch — or who store the min_epoch value in the same write-accessible store as the blob — have no protection against blob rollback.
Substitution of a different session's blob: A blob from a different session fails immediately at AAD reconstruction — the sender_fp and recipient_fp embedded in the ratchet state's AAD scheme (§6.8) will not match the expected values for this session, causing AeadFailed before any ratchet state is loaded. No cross-session confusion is possible without breaking the ratchet AEAD.
Countermeasures are documented in §6.8 (anti-rollback epoch guard, Caller Obligation 2) but are not automatically enforced — they require explicit caller action. Application authors and binding authors MUST implement the epoch-store pattern. See §6.8 for the full caller obligation list.
14.9 Pre-Key Exhaustion
Per-source-per-target rate limiting. Sessions without OPK secure with reduced initial FS.
Reduced initial FS — concrete window: When no OPK is used, the session's initial forward secrecy is bounded by the SPK's lifetime. SPKs are rotated weekly and retained for 30 days after rotation (Appendix B). Therefore, for an OPK-absent session, an attacker who later obtains the SPK private key (before it is deleted at 30 days post-rotation) can recover the session's initial shared secrets. The forward secrecy window is up to 30 days from session initiation — not the one-time, delete-on-use guarantee that OPK provides. For OPK-present sessions, deleting sk_OPK immediately after receive_session terminates the forward secrecy vulnerability window at that point, independent of SPK lifetime. Implementers calibrating OPK replenishment thresholds and the rate-limiting policy should note that OPK exhaustion degrades forward secrecy from "delete-on-use" to "30-day window," not to "no forward secrecy" — the SPK still provides forward secrecy after its private key is deleted.
14.10 Metadata
Relay knows sender/recipient/timing. No IP logging. Tor/VPN for elevated threats. DM padding is mandatory (Protocol §15.1); community padding is optional (Protocol §15.2).
14.11 Forced Session Reset
What an adversary gains from a forced reset: Denial of service — the session's in-flight messages become permanently undecryptable (the ratchet state is zeroized) and both parties must establish a new session via LO-KEX. The adversary learns nothing new: the post-reset state is all-zeros with no key material remaining.
Forward secrecy after reset: Reset does NOT provide retroactive forward secrecy for pre-reset messages. Messages encrypted before the reset remain at risk if the pre-reset epoch was already compromised. Reset terminates an active session; it does not erase the adversary's copy of previously captured ciphertext.
What a forced reset gives an attacker: Forcing a reset requires the attacker to produce a ratchet state inconsistency that triggers §6.9 recommendation 4 (unrecoverable decryption failure → call reset()). An attacker who can inject malformed ciphertexts can trigger repeated resets, denying service (all in-flight messages permanently lost per reset). This is no worse than the baseline capability of dropping messages — message suppression already prevents delivery — but repeated resets additionally force LO-KEX re-establishment overhead.
Mutual reset prerequisite: A reset by one party does not automatically reset the other's state. For the conversation to resume, both parties must independently detect the desynchronization (e.g., via application-layer re-key request) and perform new LO-KEX exchanges. An asymmetric reset — one party resets, the other does not — produces permanent desynchronization with no error distinguishable from transport loss.
14.12 KEM Ratchet — Single-Sided Randomness
LO-Ratchet's KEM ratchet differs from the Double Ratchet's DH ratchet in a fundamental way: only the encapsulator (new sender) contributes fresh randomness per step. In a DH ratchet, both parties contribute private key material to the shared secret. In a KEM ratchet, the decapsulator's contribution is their existing ratchet public key from a previous step.
Implication: If the encapsulator's RNG is compromised during a KEM ratchet step, that step does not advance forward secrecy. However, an RNG failure is catastrophic regardless — ephemeral keys, nonces, and all security-critical random values generated during the failure window are equally compromised. The ratchet's single-sided randomness is the least of the problems. Mitigating it would require bidirectional KEM per ratchet step (mandatory round-trip, doubled ciphertext) — costs that address a scenario already catastrophic for independent reasons.
This property is inherent to all KEM-based ratchets, not specific to LO-Ratchet. Mitigation: use the OS CSPRNG exclusively (getrandom).
14.13 Header Size Side Channel
KEM-ratchet-step headers are observably larger than same-chain headers. A ratchet header that includes a KEM ciphertext (has_kem_ct = 0x01) encodes to exactly 2,347 bytes on the wire (§7.4, Appendix C). A same-chain header (no KEM ciphertext, has_kem_ct = 0x00) encodes to exactly 1,225 bytes. The exact 1,122-byte difference is directly observable by any passive network adversary, regardless of transport encryption (the header is inside the encrypted channel, but the message size is observable as a traffic feature).
What this leaks: A passive adversary observing message sizes can infer when a party changes send direction (the encapsulating party's header grows by ~1,122 bytes). In a typical DM exchange, this reveals the alternating communication pattern — who initiated each new "round" of the conversation. This does not reveal message content or timing of individual messages within a round, but it does reveal the coarse structure of who speaks next after a silence.
Normative position: This is accepted leakage. LO's threat model (§14.10) acknowledges that relay operators observe communication metadata (sender, recipient, timing). Header size is a metadata feature visible at the same layer as message timing and count. Mitigating it would require padding all headers to a fixed size (2,347 bytes), adding ~1,122 bytes of overhead to every non-ratchet-step message — approximately doubling header overhead for typical high-frequency exchanges. The security benefit (hiding direction changes) is low: direction changes are correlated with reply events, which are already inferable from timing alone. Implementations MUST NOT treat this as a bug; this is a documented, accepted property.
Transport-layer mitigation (optional): Transports that pad traffic to fixed-size cells (e.g., QUIC with datagram padding, LO's Protocol §15.1 DM padding) may partially obscure this difference. This is a transport-layer concern, not a cryptographic one.
14.14 Version Downgrade Policy
LO uses a hard-fail version policy. verify_bundle rejects any crypto_version other than the currently supported version ("lo-crypto-v1"). There is no version negotiation, no silent fallback, and no "choose best supported" logic. An unrecognized version is treated as a malformed bundle — the session is aborted and the user is warned.
This eliminates downgrade attacks by design: an attacker who modifies crypto_version in a relayed bundle causes rejection, not degraded security. The crypto_version field is not signed (§5.3), but tampering with it produces the same outcome as dropping the bundle entirely — the attacker's best outcome is message suppression (denial of service), not weakened cryptography, which is no worse than the Dolev-Yao baseline capability of dropping messages.
Migration window downgrade risk (forward-looking — no v2 is currently defined; these are design requirements for a future migration window): The above guarantee holds only when a single version is in operation. During a v1→v2 migration window (where both versions are accepted), the crypto_version field is not signed and a network attacker relaying a bundle can substitute "lo-crypto-v2" with "lo-crypto-v1" — causing a v2-capable initiator connecting to a v2 peer to silently negotiate v1 instead. Unlike the single-version case, this substitution does NOT cause rejection (v1 is still accepted during the window), so the attacker's outcome is not message suppression but downgrade. Requirement for v2 deployment: The v2 pre-key bundle MUST sign the crypto_version field to prevent this substitution. This is a known gap in the current single-version design (§5.3 explicitly excludes crypto_version from the SPK signature); it MUST be corrected before deploying a migration window. Alternatively, bundle integrity can be protected at the transport layer (e.g., QUIC with server-authenticated certificate + pinning), ensuring that relay substitution is detectable. The v2 pre-key bundle format — including the signing message structure, wire layout, and migration mechanism — is out of scope for this specification. Implementers MUST NOT attempt to deploy a v2 migration window based solely on this note. The v2 format will be defined in a future version of this spec; v1 and any future v2 are disjoint wire formats with no backward-compatible relationship defined here.
(The following describes the intended migration mechanism — no v2 protocol is currently defined; no negotiation infrastructure need be built until v2 is specified.) Future version transitions (e.g., lo-crypto-v2) will support exactly two versions during a migration window. The older version will be removed in a subsequent release. At no point will more than two versions be accepted concurrently. Migration mechanism: The initiator reads crypto_version from the recipient's pre-key bundle and uses the highest mutually supported version. There is no separate negotiation handshake — version selection is implicit in the bundle. During a v1→v2 migration window, a v2-capable initiator connecting to a v1 peer (whose bundle advertises "lo-crypto-v1") uses v1. A v2-capable initiator connecting to a v2 peer uses v2. A v1-only initiator connecting to a v2-only peer fails at verify_bundle (unrecognized version). The recipient's bundle is the sole version signal; the initiator MUST NOT exceed the recipient's advertised version.
Dual role of crypto_version: The "lo-crypto-v1" string serves two independent purposes: (1) a wire field in pre-key bundles and session init, triggering hard-fail version rejection in verify_bundle and decode_session_init; and (2) a KDF domain separator embedded in the HKDF info (§5.4 Step 4), binding the session key derivation to the protocol version. A v2 migration requires changing both, for different reasons — the wire field for compatibility gating, the KDF label for cryptographic domain separation. Changing only the wire field would produce a version that hard-fails bundle verification but would derive the same session keys as v1 if it somehow bypassed the check. Changing only the KDF label would silently produce incompatible keys while the wire field still accepts the old version. Both must change atomically.
KDF label mismatch is undetectable at session establishment. When a version mismatch causes the initiator and responder to derive session keys using different crypto_version strings in the HKDF info, receive_session succeeds and init_alice/init_bob initialize without error — the divergent keys are not compared. The first observable symptom is AeadFailed at decrypt_first_message, with no diagnostic distinguishing "mismatched crypto_version in KDF" from "corrupted ciphertext" or "wrong session keys." An implementer of the migration window who mismatches the KDF label while matching the wire field will see what appears to be random AEAD failures with no obvious cause. The safe verification: after receive_session, compare the negotiated crypto_version from the SessionInit against both parties' KDF labels before proceeding.
Independent versioning axes: crypto_version (e.g., "lo-crypto-v1") governs session establishment (§5) — it determines KEM algorithms, HKDF labels, and wire formats for the key exchange. The ratchet blob version byte (§6.8, currently 0x01) governs ratchet state serialization and is independent. A new optional field in the ratchet blob increments only the blob version, not the protocol version. A lo-crypto-v2 transition would require, at minimum, a new crypto_version string and updated HKDF labels; the blob version can remain 0x01 if the ratchet format is unchanged. Similarly, the streaming AEAD header version (§15.2, currently 0x01) and storage blob format are separate versioning axes.
14.15 Non-Deniability
LO-KEX and LO-Ratchet do not provide deniability. Any message ciphertext and ratchet header can be cryptographically attributed to the sender: the sender_sig in §5.4 Step 6 is a hybrid signature (Ed25519 + ML-DSA-65) binding Alice's long-term identity key to the session init, and the AAD scheme (§6.5) binds each ratchet message to both parties' fingerprints. An adversary who obtains a session transcript can verify that the session was established with Alice's identity key. This is intentional — LO's threat model prioritizes verifiable authenticated channels over offline deniability (Signal's approach). Applications requiring offline deniability (e.g., protection against coerced evidence disclosure) MUST use an additional repudiability layer — such as omitting long-term signatures from stored transcripts, using per-session ephemeral signing keys without long-term key binding, or employing a deniable symmetric-key scheme for message bodies — and should not rely on soliton alone.
The §5.6 brief paragraph on deniability refers specifically to the short-term deniability window provided by the ephemeral KEM ciphertext: during session establishment, an observer who does not hold Alice's identity key cannot confirm authorship of the session init ciphertext (since Alice's EK_sk is ephemeral). However, once sender_sig is verified and the session is established, deniability is lost — the long-term identity binding is irrevocable.
14.16 Streaming AEAD and Ratchet Key Exposure
Per-stream random keys (§15.1) limit the blast radius of a ratchet epoch compromise. An adversary who compromises a ratchet epoch key can recover stream keys that transited that epoch — the stream key is transmitted inside a ratchet-encrypted message alongside stream metadata, so the epoch key decrypts the message and recovers the stream key. However, only streams whose keys were transmitted during the compromised epoch are affected; streams whose keys transited different epochs remain protected.
Batching multiple stream keys in a single ratchet message multiplies exposure — a single epoch compromise recovers all batched keys. The recommended pattern: one stream key per ratchet message. For streams spanning many chunks (large file transfers), the long-lived stream key's exposure window equals the ratchet epoch during which it was transmitted, regardless of the stream's total duration or chunk count.
Random-access-only callers of decrypt_chunk_at have no replay protection: Applications that use decrypt_chunk_at exclusively — never the sequential decrypt_chunk — have zero anti-replay protection. In sequential mode, presenting a previously-decrypted chunk at the same index incidentally fails because next_index has already advanced past that position (AEAD runs against the wrong index-derived nonce). In random-access mode, presenting the same (index, chunk) pair a second time succeeds identically — decrypt_chunk_at is stateless and has no memory of prior decryptions. The only cryptographic freshness binding is the per-stream CSPRNG-unique key (§15.1); within a single stream, any valid (key, index, chunk) triple is always decryptable. Applications building file delivery, random-access video streaming, or any repeat-query-capable API on top of decrypt_chunk_at MUST track successfully-decrypted indices at the application layer or arrange for single-use key material (§15.1). This threat does not require breaking the ratchet — it only requires access to the (key, chunk) material already held by the application. See §15.12 "Chunk replay" for the behavioral details.
Out-of-band key delivery (distinct threat from epoch compromise): If a stream key is delivered via an unencrypted or weakly-authenticated channel — for example, via plaintext HTTP, an unauthenticated metadata API, or a push notification service with no end-to-end encryption — the stream is protected only by transport security on the key delivery path, not by the ratchet. An adversary who intercepts the key delivery (e.g., via MITM on the key delivery channel, server compromise, or push notification interception) can decrypt all stream chunks even if the ratchet session itself is fully uncompromised. This is a distinct threat from the ratchet epoch compromise scenario above: epoch compromise requires breaking the ratchet's cryptographic properties; out-of-band key exposure requires only access to the unprotected delivery channel. The mitigation is the same pattern specified in §15.1: always deliver stream keys inside ratchet-encrypted messages, never via separate channels. Any deviation from this pattern removes the ratchet's protection for the affected streams.
Streaming AEAD format version bumps use the version byte, not a new label: The streaming header version byte (0x01) is included in every per-chunk AAD (§15.4), which provides cryptographic domain separation between format versions. A v2 streaming format MUST increment the version byte (reader sees 0x02 → UnsupportedVersion, or negotiates accordingly). Adding a new label string (e.g., "lo-stream-v2") would be redundant — the version byte in AAD already provides the domain separation. Conversely, a format change that does NOT increment the version byte but uses a different label produces ciphertexts that are undistinguishable at the header level from v1, causing opaque AEAD failures rather than clean UnsupportedVersion errors.
14.17 Post-Compromise Security Healing Boundary
LO-Ratchet provides post-compromise security (PCS) — recovery of message confidentiality after a transient key compromise, without requiring a new session. The healing mechanics and boundary conditions are specified in §6.13. Key points for formal models:
- Healing event (initial step): PCS healing begins at the KEM decapsulation step on the previously-compromised party — specifically, when the compromised party receives and successfully decapsulates a message containing a new
ratchet_pkfrom the uncompromised peer. After this step, new-epoch messages are immediately protected by arecv_epoch_keyunknown to the attacker. The uncompromised party's encapsulation step (which sends the new KEM ciphertext) does not itself heal the compromised party. Full healing requires a second KEM ratchet step — after the first decapsulation,prev_recv_epoch_keystill holds the compromised epoch key (now as the previous-epoch backup). An attacker with the compromised key can still decrypt late-arriving previous-epoch messages until the second KEM ratchet step discardsprev_recv_epoch_key. See §6.13 forFullyHealed(session, t₄)— the formal two-step healing definition. - Why decapsulation is the boundary: Before decapsulation, the compromised party still holds old epoch keys derivable from known state. After decapsulation, the new
root_keyandsend_epoch_keyderive from a KEM shared secret that was never exposed — the attacker who held the old epoch key cannot reproduce the new epoch keys. The healing epoch therefore begins at the decapsulation event on the previously-compromised side. - One-directional streams do not heal: If the compromised party never receives a message from the uncompromised peer (and therefore never decapsulates a new KEM ciphertext), no KEM ratchet step occurs and PCS healing never happens. See §6.13 for the full list of PCS boundary conditions and exclusions.
- Known weakening —
prev_recv_epoch_keysurvives the first KEM ratchet step:Corrupt(RatchetState)at any point after the first KEM ratchet step but before the second still exposesprev_recv_epoch_key— the previous epoch's key is retained for one grace period (§6.6). An attacker who compromises the session state between the first and second KEM ratchet steps can decrypt all messages from the prior epoch usingprev_recv_epoch_key, even though new-epoch messages (protected by the freshly-derivedrecv_epoch_key) are safe. This corresponds to Abstract.md Theorem 4 / Lemma 4b:FullyHealed(session, t)requirestto be after the second KEM ratchet step, not the first. This is a deliberate design tradeoff — retainingprev_recv_epoch_keyfor one step enables decryption of late-arriving previous-epoch messages without storing per-message keys. Thetwo_kem_ratchets_expire_old_epochintegration test is the empirical evidence that this two-step behavior is intentional and tested, not an oversight. Formal models of the PCS property MUST use theFullyHealedpredicate from §6.13 (which captures the two-step boundary), not a simpler "healed after one ratchet step" approximation. - Formal modelers: A PCS lemma derived from §14 without consulting §6.13 risks placing the healing event at the wrong point in the protocol transcript. The normative PCS specification is §6.13; §14 provides the threat-model framing only.
14.18 New-Epoch Path as Unauthenticated KEM Decapsulation Oracle
Every incoming ratchet message whose header.ratchet_pk does not match recv_ratchet_pk or prev_recv_ratchet_pk takes the new-epoch path and triggers a full X-Wing decapsulation (dominated by ML-KEM-768). ML-KEM implicit rejection (§8.4) means this operation never returns an error for invalid inputs — a mismatched or maliciously crafted ratchet_pk produces a pseudorandom shared secret, which derives a wrong recv_epoch_key, which causes AEAD failure, which triggers snapshot rollback. The session is unharmed, but the decapsulation was performed unconditionally.
Performance context: In the pure-Rust implementation on modern 64-bit hardware, a full new-epoch path execution (X-Wing decapsulation + KDF + AEAD failure + rollback) completes well under 10 µs per message. This was measured by the soliton fuzzer sustaining over 190 000 executions per second per core. At this rate, a sustained injection of 190 000 crafted messages per second consumes at most one CPU core — no more than any other high-throughput CPU-bound workload. On 64-bit hardware this is not a meaningful denial-of-service vector.
Accepted tradeoff: The epoch routing decision (§6.6) relies solely on comparing header.ratchet_pk — a cleartext field — against stored public keys. No authentication occurs before decapsulation. Deferring decapsulation to post-authentication would require knowing the correct epoch key before AEAD runs, creating a circular dependency. This means the KEM decapsulation step is inherently unauthenticated. The cleartext ratchet_pk field already reveals epoch transitions to any observer, so the timing of the new-epoch path is not a novel information leak — it is observable from the public key value alone.
Residual concern: The performance characterization above applies to modern 64-bit hardware. Deployments on severely resource-constrained targets (e.g., hobby-grade 32-bit microcontrollers) where ML-KEM-768 decapsulation is orders of magnitude slower should evaluate whether transport-layer sender authentication is appropriate before messages reach the ratchet layer. soliton does not target 32-bit platforms and offers no performance guarantees for them.
§15 Streaming AEAD
Chunked authenticated encryption for large payloads (file transfer, attachments). Enables disk-to-disk encryption in fixed-size chunks without holding the full payload in memory. Inspired by the STREAM construction (Hoang, Reyhanitabar, Rogaway, Vizár, 2015) but uses counter-based nonce derivation for random-access decryption rather than ciphertext chaining.
15.1 Construction
Each stream uses a single caller-provided 32-byte key and a random 24-byte base nonce (generated from the OS CSPRNG). The key MUST be freshly generated from the OS CSPRNG for each stream — reusing a key across streams is catastrophic (see §15.12). The key is not managed by the library — key wrapping is the caller's responsibility (the standard pattern: generate a random 32-byte key, encrypt the stream, then encrypt the key in a ratchet message alongside the stream metadata). Deriving the streaming key deterministically from ratchet material is unsafe: ratchet compromise would propagate to all streaming keys derived from the compromised epoch, defeating the per-stream isolation that fresh randomness provides. Plaintext is split into 1 MiB chunks, each independently encrypted with XChaCha20-Poly1305 using a per-chunk nonce derived from the base nonce and chunk index.
Security model: The stream header (including base_nonce) is not secret — an adversary who knows the header and all ciphertexts but not the key cannot decrypt any chunk. The key is the sole secret; it is not contained in or recoverable from the header. Losing the key makes the stream permanently undecryptable.
No KDF step: The caller-provided key is used directly as the XChaCha20-Poly1305 key — there is no HKDF or other derivation step between the input key and the AEAD key. No KDF is needed because the caller is required to supply a fresh 256-bit CSPRNG key (§15.1 "key MUST be freshly generated from the OS CSPRNG"): a uniformly distributed 256-bit value already saturates XChaCha20-Poly1305's key entropy, so HKDF's extract phase adds no security benefit. A reimplementer who adds a KDF step (e.g., HKDF-SHA3-256(key, base_nonce, "stream")) produces incompatible ciphertext that the reference implementation cannot decrypt.
All-zero key policy: Unlike the storage keyring (§11.6), which explicitly rejects all-zero keys via constant-time check, the streaming layer does not validate that the caller-provided key is non-zero. This is a caller obligation. The storage layer's active guard exists because keys are long-lived and stored in a keyring managed by the library; the streaming layer's keys are ephemeral, caller-provided, and used once — validating them would shift a caller responsibility into a layer that cannot meaningfully enforce it (the caller could pass any weak key, not just all-zeros). A reimplementer who adds an all-zero guard to the streaming layer for "consistency" with storage creates a behavioral divergence from the specification.
Caller key zeroization: The library copies the key into the opaque encryptor/decryptor handle on initialization. The caller's original key buffer is not zeroed by the library. After calling soliton_stream_encrypt_init or soliton_stream_decrypt_init, the caller MUST zeroize their copy of the key via soliton_zeroize (CAPI) or Zeroizing wrapper (Rust). The handle's internal copy is zeroized automatically when the handle is freed.
15.2 Wire Format
Buffer-allocation quick reference: All streaming sizes (
STREAM_HEADER_SIZE,CHUNK_SIZE,STREAM_CHUNK_OVERHEAD,STREAM_ZSTD_OVERHEAD,STREAM_ENCRYPT_MAX,STREAM_CHUNK_STRIDE) are defined with their derivations in Appendix A. A consolidated buffer-sizing summary table for binding authors is in Appendix B.
Stream = Header || Chunk₀ || Chunk₁ || ... || ChunkN
Header (26 bytes):
version (1) — stream format version (0x01)
flags (1) — bit 0: compression (0 = none, 1 = zstd), bits 1-7: reserved (must be zero)
base_nonce (24) — random, unique per stream
Chunk:
tag_byte (1) — 0x00 = non-final, 0x01 = final
ciphertext (variable) — AEAD output: encrypted plaintext + 16-byte Poly1305 tag
tag_byte interpretation: On decrypt, only the value 0x01 is treated as final. Any other value (including 0x00 and hypothetical future values) is treated as non-final. Implementations MUST NOT reject unknown tag_byte values pre-AEAD — the tag_byte is authenticated via inclusion in both the nonce (§15.3) and the AAD (§15.4), so a chunk with a hypothetical future tag_byte 0x02 from a newer writer would fail AEAD on any older reader (the nonce and AAD would differ from what the encryptor used). The "lenient decoding" means: don't add a pre-AEAD guard that rejects non-0x00/0x01 values, because AEAD already provides the rejection. On encrypt, only 0x00 and 0x01 are produced.
The library does not embed length prefixes between chunks. Chunk delimitation is a transport/storage concern — different transports (QUIC, WebSocket, HTTP/2) and storage backends (object stores, flat files) have different framing mechanisms.
Compressed stream chunk framing is NOT specified as an interoperability format: This spec does not define a normative chunk-length framing format for compressed streams. The compressed streaming feature (flags & 0x01 == 1) is a single-implementation feature: it is designed to be written and read by the same soliton implementation (or a reimplementation that derives its own chunk framing scheme from this spec). The wire format specifies the AEAD construction, the nonce derivation, and the header layout — but NOT how the variable-length compressed ciphertext chunks are delimited on the wire when transported across a byte stream. A reimplementer who builds an independent implementation targeting cross-implementation interoperability for compressed streams MUST define and negotiate a chunk framing mechanism out-of-band (e.g., HTTP chunked encoding, a length-prefix layer, or an out-of-band chunk index). Without this, two independent compressed-stream implementations will fail to interoperate at the transport level even though their AEAD layer is identical.
Compressed streams are NOT self-delimiting: For a compressed stream (flags & 0x01 == 1), non-final chunks have variable ciphertext size (1 to CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + 16 bytes, depending on content and compression ratio). There is no fixed stride — the transport MUST provide per-chunk lengths (e.g., HTTP chunked encoding, a length-prefix framing layer, or an index built during encryption). A reimplementer who applies the 1,048,593-byte fixed-stride read algorithm to a compressed stream will misalign at the first chunk boundary, causing all subsequent AEAD decryptions to fail.
Recommended framing for compressed streams: When transporting a compressed stream over a raw byte channel (file, TCP socket, UNIX pipe), implementers SHOULD prefix each chunk's ciphertext with a 4-byte big-endian u32 length field giving the ciphertext byte count (not including the tag_byte or the length prefix itself). The on-wire layout per chunk becomes tag_byte (1) || ciphertext_len (4, BE u32) || ciphertext (ciphertext_len bytes). ciphertext_len is the AEAD output byte count: len(compressed_plaintext) + 16 — the 16-byte Poly1305 authentication tag is part of the ciphertext and is included in ciphertext_len, not a separate field. For an empty final chunk (0 bytes of compressed plaintext), ciphertext_len = 16. A reimplementer who excludes the 16-byte Poly1305 tag from ciphertext_len (treating it as overhead outside the count) produces length values 16 bytes short per chunk, causing the reader's framing to misalign immediately after the first chunk. This framing is simple, has zero overhead relative to AEAD (the ciphertext already contains the Poly1305 tag), and enables a reader to allocate exactly the right buffer for each chunk without look-ahead. Implementations that deviate from this framing for compressed streams will be silently incompatible with conforming implementations at the transport layer even though their AEAD output is identical. Uncompressed streams do not need this framing — the fixed stride already provides delimitation (§15.2 above).
Uncompressed stream sequential read algorithm: For an uncompressed stream (flags & 0x01 == 0), every non-final chunk is exactly 1,048,593 bytes on the wire (1 tag_byte + 1,048,576 plaintext bytes encrypted to 1,048,576 + 16 AEAD ciphertext bytes = 1,048,593 total). A sequential reader reads fixed-size chunks until it encounters a chunk with tag_byte = 0x01 (final). Because the wire size is fully determined by CHUNK_SIZE (1,048,576 bytes, see §A Constants), a streaming implementation can read exactly 1,048,593 bytes per non-final chunk without a length prefix — no look-ahead required. This derivation combines §15.2 (wire format) and §15.6 (chunk sizing); it is stated here to spare streaming-layer implementers from reconstructing it.
Short final chunk from an unframed transport: The final chunk has variable ciphertext size (17 bytes minimum — 1 tag_byte + 16 Poly1305 tag for empty plaintext — up to 1,048,593 bytes). When reading from an unframed byte stream (raw TCP, file read), the algorithm is: attempt to read 1,048,593 bytes; if the transport delivers fewer bytes (because it reached EOF or end-of-stream), those fewer bytes constitute the final chunk. The size shortfall is not an error — it is the signal that the final chunk has been received. A reimplementer who requires exactly 1,048,593 bytes for every chunk (including the final) will reject all non-MiB-boundary streams. Note: pre-framed transports (QUIC streams, WebSocket messages, HTTP chunked encoding) deliver chunks with explicit boundaries and do not exhibit this ambiguity.
Minimum final chunk size and transport accumulation obligation: A final chunk delivered with fewer than 17 bytes returns AeadFailed — the 16-byte Poly1305 tag plus 1 tag_byte is the irreducible minimum (encrypting zero plaintext bytes produces a 16-byte tag with no ciphertext). Transport implementations MUST accumulate bytes until either 1,048,593 bytes are in hand (a full non-final stride) or a clean stream EOF signal before presenting a chunk to the decryption layer. Presenting a partial chunk (e.g., 8 bytes of a truncated stream) to decrypt_chunk returns AeadFailed with no indication of whether the data was truncated in transit or the key/nonce was wrong — the AEAD cannot distinguish these cases.
15.3 Nonce Derivation
Per-chunk nonce is derived by XORing a 24-byte mask into the base nonce:
mask = chunk_index (8 bytes, big-endian u64)
|| tag_byte (1 byte: 0x00 = non-final, 0x01 = final)
|| 0x00 * 15 (15 zero bytes, padding)
chunk_nonce = base_nonce XOR mask
| Bytes | Mask content | Purpose |
|---|---|---|
| 0-7 | chunk_index (u64 BE) |
Distinct nonce per chunk position |
| 8 | tag_byte |
Distinct nonce for final vs non-final at same index |
| 9-23 | 0x00 |
No effect on base nonce (XOR with zero is identity) |
These bytes MUST be zero in the mask. A reimplementer who places additional data in bytes 9-23 of the mask produces nonces incompatible with any conforming implementation.
Injectivity: For two chunks (i₁, t₁) and (i₂, t₂), mask₁ = mask₂ iff i₁ = i₂ and t₁ = t₂. Since XOR with a constant is a bijection, distinct (index, tag_byte) pairs always produce distinct nonces.
15.4 AAD Construction
aad = "lo-stream-v1" // 12 bytes, domain label
|| version // 1 byte
|| flags // 1 byte
|| base_nonce // 24 bytes
|| chunk_index // 8 bytes, big-endian u64
|| tag_byte // 1 byte (0x00 or 0x01)
|| caller_aad // variable, caller-supplied context
Total AAD: 47 + len(caller_aad) bytes. caller_aad is optional application-level context (file ID, channel ID) provided once at stream init and constant across all chunks. caller_aad is not treated as secret material. The library stores it in a plain buffer without Zeroizing and does not zeroize it on handle destruction. Callers MUST NOT pass sensitive values (private paths, internal batch IDs, authentication tokens) as caller_aad — use only public or non-sensitive identifiers. It is the terminal field with no length prefix — the first 47 bytes have a fixed layout, so caller_aad is unambiguously everything after byte 46. Omitting the length prefix is intentional, not an oversight. A reimplementer who adds a 2-byte BE length prefix for consistency with other length-prefixed fields in the protocol (e.g., §7.4's session init encoding) produces different AAD bytes and AEAD authentication failure. The implementation captures caller_aad at init time and reuses the same bytes for every chunk. A reimplementer constructing per-chunk AAD manually MUST use identical caller_aad bytes for every chunk in the stream — varying the caller portion produces AEAD authentication failure on decrypt with no diagnostic indicating which field changed.
caller_aad is a raw byte string — C callers MUST NOT use strlen() to derive its length: caller_aad may contain null bytes (e.g., a binary UUID, a binary file identifier, an all-zero channel ID). C binding authors who pass strlen(aad) as aad_len silently truncate caller_aad at the first null byte, producing wrong AAD and AeadFailed on every decrypt_chunk call. Always pass the explicit byte count: soliton_stream_decrypt_init(key, key_len, header, header_len, aad, aad_len, out) where aad_len = sizeof(aad_array) or a separately-tracked length, never strlen(aad).
caller_aad mismatch is not detected at stream_decrypt_init: stream_decrypt_init accepts any caller_aad bytes without checking them against the stream's encrypted header (the header contains only version, flags, and base_nonce — there is no stored hash or commitment of caller_aad). A mismatch between the encrypt-side and decrypt-side caller_aad values first manifests as AeadFailed on the first decrypt_chunk call. Callers who supply a wrong caller_aad to stream_decrypt_init will always receive AeadFailed from decrypt_chunk, not from decrypt_init, with no indication at init time that the context binding is wrong.
| AAD component | Prevents |
|---|---|
version |
Version downgrade |
flags |
Flag flipping (e.g., compression flag → skip decompression) |
chunk_index |
Chunk reordering |
base_nonce |
Cross-stream splicing |
tag_byte |
Truncation (stripping final marker) |
caller_aad |
Context confusion (file from channel X served as channel Y) |
caller_aad size recommendation: caller_aad is semantically a file ID, channel ID, or similar context identifier — typically a few bytes to a few hundred. There is no protocol-level size limit (the CAPI 256 MiB general input cap applies), but large values produce multiplicative work: every chunk's AEAD runs Poly1305 over 47 + len(caller_aad) bytes of AAD. With a 256 MiB caller_aad and thousands of chunks, the aggregate AAD processing dominates total encryption time. Recommended maximum: 4096 bytes. Applications needing to bind larger context should hash it first (e.g., SHA3-256(full_context)) and pass the 32-byte digest as caller_aad.
15.5 Compression
Per-chunk zstd compression (Zstandard, RFC 8878), controlled by flags bit 0. When enabled, each chunk's plaintext is independently compressed before encryption. Empty plaintext (0-byte final chunk) bypasses compression regardless of the flag.
"Non-empty" check applies to post-AEAD plaintext, not ciphertext: The bypass condition for empty final chunks is checked on the plaintext after AEAD decryption, not on the raw ciphertext length. A 16-byte ciphertext (Poly1305 tag only, decrypting to 0 bytes of plaintext) is empty by this definition; a ciphertext whose decrypted content is 0 bytes after decompression is also empty. A reimplementer who checks ciphertext.len() == 0 (before decryption) instead of plaintext.len() == 0 (after decryption) will incorrectly attempt zstd decompression on a 0-byte buffer — resulting in a decompression error that collapses to AeadFailed (§15.7) with no diagnostic pointing to the misplaced check.
flags is a stream-level constant, not a per-chunk value. The flags byte is set once at stream initialization and appears identically in every chunk's AAD (§15.4) — including the final chunk, even when that chunk is empty and compression is bypassed. A reimplementer who interprets flags as "was this specific chunk compressed" and writes 0x00 for the empty final chunk when compress = true produces an AAD mismatch and AEAD failure on decrypt. The flags byte records the stream's compression configuration, not the per-chunk compression outcome.
Pipeline:
- Encrypt (compression enabled, non-empty): plaintext → zstd compress → AEAD encrypt → prepend tag_byte.
- Encrypt (compression disabled, or empty): plaintext → AEAD encrypt → prepend tag_byte.
- Decrypt: read tag_byte → AEAD decrypt → (if compressed and non-empty) zstd decompress → plaintext.
Caller-visible buffer layout: encrypt_chunk produces a single output buffer containing tag_byte (1) || AEAD_ciphertext (plaintext_len + 16). The tag_byte is prepended and returned as part of the output — callers do NOT append it separately. decrypt_chunk expects the same layout as input: tag_byte (1) || AEAD_ciphertext. A reimplementer who returns only the AEAD ciphertext (without tag_byte) from encrypt_chunk, expecting the caller to prepend it, produces an API that is incompatible with the standard wire format and with CAPI callers who use the output buffer directly.
Compression level: Fastest (~1), matching encrypt_blob. Pure Rust via ruzstd. No dictionary (per-chunk independent, required for random access). Max decompressed size per chunk: CHUNK_SIZE (1 MiB).
Compression oracle (CRIME/BREACH): When attacker-controlled content is mixed with secret data in the same chunk, the per-chunk compressed size leaks information about the secret via adaptive chosen-plaintext. An attacker who can influence the plaintext and observe chunk wire sizes can iteratively extract secrets by measuring compression ratios. Since chunks compress independently with no cross-chunk dictionary, this oracle is bounded to within a single chunk — an attacker who places controlled content in chunk 0 cannot learn anything about secrets in chunk 5. Callers who separate attacker-influenced data from secrets across chunk boundaries do not need to disable compression for the entire stream. Use compress = false only when attacker-influenced data and secrets coexist within the same chunk (e.g., a single chunk containing both a user-supplied filename and session metadata).
15.6 Chunk Sizing
Non-final chunks: plaintext MUST be exactly CHUNK_SIZE (1 MiB). Enforced on both encrypt and decrypt sides. The timing of the size check differs by compression mode, and this asymmetry is security-relevant:
- Uncompressed: The on-wire chunk is
tag_byte (1) || AEAD_ciphertext (CHUNK_SIZE + 16)— total wire sizeCHUNK_SIZE + 17(=CHUNK_SIZE + CHUNK_OVERHEAD). After reading thetag_bytebyte, the AEAD ciphertext size is deterministic (CHUNK_SIZE + 16). The decryptor checks the AEAD ciphertext length pre-AEAD (framing check,InvalidData) before attempting decryption. "Chunk wire length" in this context means the AEAD ciphertext portion (not counting the already-readtag_byte). A reimplementer who defers this check to post-AEAD wastes cycles decrypting malformed chunks. - Compressed: ciphertext size is non-deterministic (compression ratio varies), so the plaintext-size check occurs post-AEAD after decrypt + decompress. The decompressed output must be exactly
CHUNK_SIZE(not merely≤ CHUNK_SIZE) — both undersized and oversized decompressed non-final chunks are rejected asAeadFailed(post-auth error collapse per §15.7). Returning a distinct error (e.g.,InvalidDataorDecompressionFailed) for either size mismatch would create a post-AEAD size oracle. A reimplementer who checks compressed chunk sizes pre-AEAD creates an oracle: rejecting a chunk before authentication reveals that the size check (not the AEAD) failed, leaking information about the expected plaintext size. No pre-AEAD ciphertext cap is applied for compressed chunks at the streaming layer — the CAPI 256 MiB input cap (§13.2) provides the outer bound. A legitimate compressed chunk is at mostSTREAM_ENCRYPT_MAX(=CHUNK_SIZE + ZSTD_OVERHEAD + CHUNK_OVERHEAD= 1,048,849 bytes — the maximum CAPI output buffer for one encrypted chunk); without a tighter cap, a peer can force AEAD attempt on up to 256 MiB of ciphertext before authentication fails. This is intentional — any tighter pre-AEAD cap would create the same oracle it is designed to prevent. Exception: a cap of exactlySTREAM_ENCRYPT_MAX(1,048,849 bytes) is safe — it eliminates only inputs no conforming encryptor could produce (the reference encryptor never outputs a compressed chunk exceedingSTREAM_ENCRYPT_MAXbytes) and does not create an oracle about the compression ratio or the expected size of valid ciphertext. The oracle concern applies only to caps tighter than the maximum conforming encryptor output.
Normative cap statement for compressed non-final chunk pre-AEAD: Implementations MAY apply a pre-AEAD ciphertext size cap of exactly STREAM_ENCRYPT_MAX (1,048,849 bytes). Implementations MUST NOT apply a pre-AEAD cap below STREAM_ENCRYPT_MAX — a cap tighter than the maximum conforming encryptor output creates the oracle it is designed to prevent (it would reject valid ciphertexts from a conforming peer, causing AeadFailed for valid data and allowing timing-based oracle inference). The reference implementation applies no tighter cap than the outer 256 MiB CAPI bound. An implementation applying the optional STREAM_ENCRYPT_MAX cap MUST return InvalidLength for ciphertext inputs exceeding that cap — not AeadFailed. The pre-AEAD size check fires before any AEAD operation, so InvalidLength is the correct variant (the input exceeds the size constraint, not the authentication check). This is an acceptable, documented divergence from the reference: the reference returns AeadFailed for oversized inputs (the 256 MiB CAPI cap returns InvalidLength, but inputs between STREAM_ENCRYPT_MAX and 256 MiB proceed to AEAD which then fails). Callers testing against both implementations MUST handle either InvalidLength or AeadFailed for inputs in the STREAM_ENCRYPT_MAX + 1 to 256 MiB range.
Accepting undersized non-final chunks in either mode would allow malformed streams where chunk boundaries are shifted, corrupting random-access offset calculations.
Encrypt-side non-final wrong-size is a caller bug — no library-level enforcement beyond the error return: The encrypt_chunk function returns InvalidData when a non-final chunk's plaintext length ≠ CHUNK_SIZE. There is no additional internal guard that prevents the caller from ignoring the error and continuing to encrypt subsequent chunks — the error is informational. The streaming state is unchanged on InvalidData from wrong chunk size (§15.11 atomicity), so a caller who ignores the error and re-calls encrypt_chunk with a different size produces a stream with inconsistent chunk sizes. This is a caller programming error; the library cannot enforce correct behavior beyond the error return for the offending call. The distinct error (InvalidData not AeadFailed) ensures this is diagnosable — it fires before AEAD, so it is safe to expose the distinction without creating an oracle.
Final chunk: plaintext may be 0..=CHUNK_SIZE. A final chunk exceeding CHUNK_SIZE is rejected as InvalidData (not InvalidLength — the type is correct (bytes of plaintext) but the value violates the chunk-size structural constraint; not AeadFailed — this is a pre-AEAD framing check on the plaintext length, not a post-authentication error). An empty file produces one final chunk (tag_byte + 16-byte AEAD tag = 17 bytes). Every valid stream has exactly one chunk with tag_byte=0x01.
Compressed final chunk decompressing beyond CHUNK_SIZE: A compressed final chunk that decompresses to more than CHUNK_SIZE bytes returns AeadFailed — the post-AEAD error collapse (§15.7) applies to all decompression-side size violations, including the final chunk. Reimplementers MUST NOT return a distinct error (DecompressionFailed, InvalidData) for this case — doing so creates an oracle distinguishing "AEAD passed, decompression size check failed" from "AEAD failed."
Minimum valid stream: 26 (header) + 17 (empty final chunk) = 43 bytes.
15.7 Error Oracle Collapse
Two categories of errors are collapsed to AeadFailed for oracle prevention, for different reasons:
-
Post-authentication errors (decompression failure, size mismatch): collapsed to prevent a 1-bit oracle distinguishing "authentication succeeded but post-processing failed" from "authentication failed." These checks fire after AEAD succeeds, so distinguishing them from
AeadFailedwould confirm that authentication passed — leaking information about key correctness. -
Pre-authentication header errors (reserved flag bits): collapsed to prevent a 1-bit oracle distinguishing "unsupported flag combination" from "wrong key." Reserved-bit checks fire at
stream_decrypt_init, before any chunk AEAD, so returning a distinct error would allow an attacker to distinguish "correct key with malformed header" from "wrong key" by probing the flag byte. This is a different oracle than the post-AEAD case but equally undesirable.
Pre-authentication checks on publicly visible fields (UnsupportedVersion for version byte, InvalidData for uncompressed chunk framing) do not create oracles because the checked values are visible to anyone who observes the header or chunk.
Error origin table for stream initialization and decryption:
| Error | Returned from | Phase |
|---|---|---|
UnsupportedVersion |
stream_decrypt_init |
Header parsing — version byte checked at init, before any chunk |
AeadFailed (reserved flag bits) |
stream_decrypt_init |
Header parsing — flag byte checked at init, before any chunk |
AeadFailed (authentication failure) |
stream_decrypt_chunk / stream_decrypt_chunk_at |
Per-chunk AEAD |
AeadFailed (decompression failure) |
stream_decrypt_chunk / stream_decrypt_chunk_at |
Post-AEAD (oracle collapse) |
AeadFailed (size mismatch post-decompress) |
stream_decrypt_chunk / stream_decrypt_chunk_at |
Post-AEAD (oracle collapse) |
InvalidData (wrong non-final chunk size, uncompressed) |
stream_decrypt_chunk / stream_decrypt_chunk_at |
Pre-AEAD framing (not oracle — checked value is public) |
AeadFailed (chunk shorter than 17 bytes) |
stream_decrypt_chunk / stream_decrypt_chunk_at |
Pre-AEAD oracle collapse — 17 bytes is the minimum valid chunk (1 tag_byte + 16-byte Poly1305 tag with zero plaintext). Returning InvalidData for chunks shorter than 17 bytes would allow an attacker to distinguish "chunk too short to attempt AEAD" from "valid-length but wrong tag." The same oracle-collapse rationale as §12's undersize-ciphertext row applies here for the streaming layer. Reimplementers who add a pre-AEAD if len(chunk) < 17: return InvalidData guard violate this requirement. |
InvalidData (oversized final chunk plaintext) |
stream_encrypt_chunk only |
Pre-AEAD framing (encrypt side only — plaintext size is known before AEAD on the encrypt path; the decrypt path has no pre-AEAD plaintext size check for the final chunk, because the decrypted size is unknown until AEAD succeeds) |
InvalidData (post-finalization call) |
stream_decrypt_chunk |
State guard |
ChainExhausted |
stream_decrypt_chunk |
Counter guard (sequential only) |
A reimplementer who places the version-byte check in the per-chunk path (returning InvalidData on each chunk that encounters an unexpected version) instead of in stream_decrypt_init will diverge from the specified error ordering — callers who check the return code of stream_decrypt_init expect to detect version mismatches before processing any chunks. The version byte appears only in the header, not per-chunk — a reimplementer checking version per-chunk is also structurally wrong (the version byte is not re-read from each chunk's data).
15.8 Version and Flags Handling
Version byte 0x01 is accepted; all other values rejected with UnsupportedVersion at init time (stream_decrypt_init), before any chunk is processed. Reserved flag bits (1-7) must be zero; non-zero reserved bits are also rejected at init time with AeadFailed (oracle collapse — attacker-controlled header field). Both checks fire during header parsing, not during the first chunk decrypt. A reimplementer who defers the reserved-bits check to per-chunk AEAD will observe different error ordering (the error appears on the first decrypt_chunk call rather than on stream_decrypt_init), producing divergent behavior in error-ordering tests.
Asymmetry rationale — why version gets UnsupportedVersion but flags get AeadFailed: The version byte is a public implementation-capability indicator. Returning UnsupportedVersion for an unknown version enables the caller to distinguish "library version too old, upgrade required" from "authentication failure" without any oracle risk — an attacker who knows the version byte (which is in the cleartext header) gains no information about key correctness by learning that the version is unsupported. The flags byte is security-relevant: an attacker who controls the flags byte and can observe the error response gains a key-verification oracle — if the correct key is loaded and only the flag is wrong, a distinct error would confirm key correctness. Collapsing flags errors to AeadFailed removes this distinguisher. In short: unknown version → caller needs to upgrade, expose clearly; unknown flags → potential adversarial probe, collapse to prevent oracle.
15.9 Chunk Index Exhaustion
The sequential encryptor and decryptor maintain a next_index: u64 counter (initially 0). Before each chunk operation, if next_index == u64::MAX, the operation returns ChainExhausted without encrypting or decrypting. This prevents next_index + 1 from wrapping to 0, which would reuse the chunk 0 nonce — catastrophic for AEAD security. The random-access decrypt_chunk_at does not maintain a sequential counter and accepts any u64 index directly, so exhaustion does not apply. Passing u64::MAX as the index is not guarded — it computes a valid nonce and attempts AEAD decryption, which will return AeadFailed (no encryptor could have produced a chunk at that index due to the sequential exhaustion guard). The nonce for index u64::MAX with a non-final tag byte is computed as base_nonce XOR (0xFFFFFFFFFFFFFFFF || 0x00 || 0x00{15}), i.e., the first 8 bytes of the mask (bytes 0-7, the chunk_index field encoded as a big-endian u64) are all 0xFF. This is a structurally valid XChaCha20-Poly1305 nonce — the AEAD proceeds, finds no matching ciphertext, and returns AeadFailed. Reimplementers MUST NOT add a ChainExhausted guard to decrypt_chunk_at — the function is stateless and cannot know whether the index is "valid."
expected_index() value after ChainExhausted: When a sequential encryptor or decryptor returns ChainExhausted (at next_index == u64::MAX), expected_index() / soliton_stream_decrypt_expected_index returns u64::MAX. The counter is not cleared, reset, or advanced on the exhaustion guard — it retains the value that triggered the guard. A reimplementer who advances or resets next_index on ChainExhausted will return the wrong value from expected_index() and break callers who inspect the counter after exhaustion to determine how many chunks were processed.
ChainExhausted boundary: A stream with exactly u64::MAX - 1 (18,446,744,073,709,551,614) chunks processes the final chunk at index u64::MAX - 1 (next_index advances from u64::MAX - 1 to u64::MAX after that chunk). The next call to encrypt_chunk or decrypt_chunk (at next_index == u64::MAX) returns ChainExhausted. Reimplementers testing this boundary MUST use the sequential API, not decrypt_chunk_at (which is stateless and does not check the counter).
decrypt_chunk_at remains usable after sequential exhaustion. When a sequential decryptor returns ChainExhausted (at next_index == u64::MAX), decrypt_chunk_at is unaffected — it reads no sequential state and can still decrypt any chunk by explicit index. This enables a valid use pattern: sequentially process all chunks up to the exhaustion boundary, then use decrypt_chunk_at for any remaining chunks. A reimplementer who adds a terminal-exhausted flag that also blocks decrypt_chunk_at breaks this pattern.
Compressed non-final chunk size validation applies to decrypt_chunk_at: The compressed non-final chunk size check (§15.6 — decompressed output MUST be exactly CHUNK_SIZE) applies to decrypt_chunk_at exactly as it does to sequential decryption. A reimplementer treating random-access decryption as "bare AEAD + decompress" without the size check accepts malformed streams where a non-final chunk decompresses to the wrong size. The check is post-AEAD (§15.6) and therefore safe — it does not create an oracle. The stateless nature of decrypt_chunk_at does not exempt it from content validation.
Empty-final-chunk compression bypass applies to decrypt_chunk_at: The §15.5 compression bypass for empty plaintext (a 0-byte final chunk is stored uncompressed regardless of the compress flag) applies identically to decrypt_chunk_at. When the decrypted AEAD output is zero bytes, the decompression step is skipped — attempting zstd_decompress([]) on an empty AEAD output would reject a structurally valid empty final chunk, collapsing to AeadFailed. A reimplementer who unconditionally decompresses the AEAD output in decrypt_chunk_at (rather than conditioning on !decrypted.is_empty()) breaks empty-file stream support.
15.10 Finalization State Machine
Both the encryptor and sequential decryptor maintain a finalized boolean (initially false) that enforces stream integrity:
-
Encrypt: Successfully encrypting a chunk with
is_last = truesetsfinalized = true. A failedencrypt_chunk(is_last=true)call (e.g.,Internalfrom zstd expansion) does NOT setfinalized— the stream is not sealed and the call is retryable (§15.11 atomicity). Subsequent successful calls toencrypt_chunk(regardless ofis_last) after finalization returnInvalidData— the stream is sealed. A reimplementer who allows post-final writes would permit appending chunks to a supposedly-complete stream, breaking the exactly-one-final-chunk invariant (§15.6). -
Decrypt (sequential): Successfully decrypting a chunk with
tag_byte = 0x01setsfinalized = true. Subsequent calls todecrypt_chunkreturnInvalidData. This prevents callers from feeding additional chunks after the stream is complete. AEAD failure on the final chunk does NOT setfinalized: ifdecrypt_chunkreturnsAeadFailedfor a chunk whosetag_bytewould have been0x01,finalizedremainsfalse. The caller may retry the chunk (e.g., after re-fetching from a corrupted transport) without hitting the post-finalization guard. A reimplementer who setsfinalized = trueon anytag_byte = 0x01attempt (including failed ones) prevents retry of a legitimately corrupted final chunk. -
Encrypt (random access):
encrypt_chunk_atdoes NOT read or setfinalized, and does NOT read or advancenext_index. It can be called before, during, or after sequential finalization — thefinalizedguard thatencrypt_chunkenforces is absent. A reimplementer who adds a post-finalization guard toencrypt_chunk_atbreaks the mixed sequential/random-access pattern and prevents callers from using parallel encryption alongside a sequential stream. Callingencrypt_chunk_at(is_last=true)after sequential finalization succeeds silently: the library emits a second final chunk (tag_byte = 0x01) with no error. The resulting stream violates the exactly-one-final invariant (§15.6) — a sequential decryptor seals at the firsttag_byte = 0x01and returnsInvalidDatafor all subsequent chunks, including the second final marker. Tracking whether a final chunk has already been emitted is a caller obligation. -
Decrypt (random access):
decrypt_chunk_atdoes NOT read or setfinalized. It can be called in any order, including afterfinalized = true, and including on the final chunk. The caller owns completion tracking when using random-access mode. The return value is(plaintext, is_last)whereis_lastreflects the decodedtag_byte(trueiftag_byte == 0x01,falseotherwise) — this is a pure read of the chunk's tag byte with no connection to thefinalizedflag. A reimplementer who omitsis_lastfrom the return value or always returnsfalseprevents callers from detecting the final chunk in random-access mode.
The finalized flag is queryable via is_finalized() on both encryptors and decryptors.
Silent truncation when freeing an unfinalized encryptor: Calling soliton_stream_encrypt_free on an encryptor where finalized = false silently destroys the handle and zeroizes the key without error. The library does NOT return InvalidData or any error for freeing an unfinalized encryptor — the free operation always succeeds. The resulting stream has no final chunk (tag_byte = 0x01 was never emitted), so any sequential decryptor reading the output will eventually reach EOF without seeing tag_byte = 0x01 and detect truncation. However, if the caller discards the partially-written stream output without checking the free return code, the truncation is silent from the caller's perspective. Callers MUST call is_finalized() before freeing an encryptor and treat a non-finalized free as a programming error. The library cannot emit the final chunk automatically on free — the final chunk carries the actual last plaintext data, which the library does not buffer. An auto-emitted empty final chunk on free would produce a spurious 17-byte trailing chunk that the caller did not request and whose plaintext (empty) may be incorrect for the application. Callers who want to guarantee a final chunk MUST call encrypt_chunk(..., is_last=true) explicitly before freeing.
Freeing an unfinalized sequential decryptor also always succeeds: Calling soliton_stream_decrypt_free on a decryptor where finalized = false (i.e., the stream was only partially consumed — the tag_byte = 0x01 final chunk was never decrypted) silently destroys the handle and zeroizes the key without error. The library does NOT return InvalidData or any error for freeing an unfinalized decryptor. Whether the absent finalization reflects a truncated stream, a partial read, or a transport failure is a caller concern; the library imposes no constraint on consuming the full stream before freeing the handle.
header() is valid immediately after stream_encrypt_init — before the first chunk. The 26-byte header (version + flags + base_nonce) is written once at construction time and never changes. The canonical usage is: init → header() → encrypt_chunk(...) × N. A reimplementer who adds a "not-yet-started" guard — returning an error from header() before the first encrypt_chunk call — breaks protocols that transmit the header before beginning chunk production (e.g., streaming pipelines that open the output channel, write the header, and then encrypt chunks as they arrive). header() is equally valid before the first chunk, between chunks, after the final chunk, and after freeing finalization (if the handle is still accessible). It is not subject to any state guard — the finalized flag, the next_index counter, and the per-chunk error states are irrelevant. A reimplementer who adds a post-finalization guard to header() also breaks this pattern (retrieving the header after the final chunk is emitted is a common pattern for container formats).
15.11 Random Access
Counter-based nonce derivation enables both encryption and decryption of any chunk without processing preceding chunks.
encrypt_chunk_at — random-access encryption: The symmetric counterpart to decrypt_chunk_at. Encrypts one chunk at an explicit index using the same nonce and AAD construction as encrypt_chunk (§15.3, §15.4), but does not advance next_index or set finalized. The primary use case is parallel encryption: the caller splits the plaintext into chunks, assigns each chunk an index, and dispatches encrypt_chunk_at calls concurrently. Because each chunk's nonce and AAD are fully determined by the chunk index, compression flag, base nonce, and key — none of which change during parallel execution — the encrypted chunks can be computed independently and assembled in index order without synchronization. The caller is responsible for:
- Assigning each chunk a unique index. Calling
encrypt_chunk_attwice with the same(index, is_last, plaintext)triple produces identical output (nonce reuse — see §15.12 index uniqueness). Calling it twice with the same index but different plaintexts produces ciphertexts that are cryptographically indistinguishable from a corruption — no oracle exists to detect them. - Marking exactly one chunk as
is_last = true. The final-chunk invariant (§15.6 exactly-one-final) is a caller obligation when usingencrypt_chunk_at. The library enforces nothing: a caller who marks two chunks as final produces a stream wheredecrypt_chunkaccepts the firsttag_byte = 0x01it encounters and returnsInvalidDatafor all subsequent chunks (§15.10 decrypt sequential finalization guard). - Knowing the total chunk count before encryption begins (to identify which chunk is final). The sequential
encrypt_chunkdoes not require this —is_lastis provided per call. Forencrypt_chunk_at, the caller must know the chunk count in advance to setis_lastcorrectly on the last chunk.
encrypt_chunk_at takes &self in Rust (immutable borrow), so multiple concurrent calls from safe Rust code (e.g., via rayon::par_iter) are permitted without unsafe — the borrow checker enforces that no mutable state is shared. The CAPI soliton_stream_encrypt_chunk_at uses *const SolitonStreamEncryptor for the same reason: the function does not mutate handle state. The CAPI reentrancy guard (§13.6) still fires on concurrent calls to the same handle, so parallel encryption through the CAPI requires one encryptor handle per thread, all initialized from the same key, AAD, and base nonce. The Rust API has no such restriction.
*const SolitonStreamEncryptor for soliton_stream_encrypt_chunk_at: The CAPI uses *const to reflect the &self Rust contract. The same caveat from soliton_stream_decrypt_chunk_at applies: in C, const T* does NOT mean concurrent calls are safe — the reentrancy guard enforces single-caller access at runtime. Parallel encryption through CAPI always requires separate handles.
Sequential and random-access encryption can be mixed on the same handle: encrypt_chunk_at never modifies next_index or finalized, so it does not interfere with a concurrent or subsequent sequential encrypt_chunk pass. For example, a caller can encrypt the bulk of a stream sequentially via encrypt_chunk, then re-encrypt a specific chunk at a known index via encrypt_chunk_at to patch it — the sequential counter is unaffected. "Mixed" means interleaved calls within a single thread in the Rust API; CAPI mixed access requires sequential calls on the same handle due to the reentrancy guard.
Decryption of random-access-encrypted streams: A stream encrypted entirely via encrypt_chunk_at is wire-format identical to one encrypted via encrypt_chunk — the wire format (§15.2) depends only on the key, base nonce, indices, and plaintexts, not on which encrypt API was used. It can be decrypted via decrypt_chunk (sequential), decrypt_chunk_at (random access), or a mix.
Counter-based nonce derivation enables decryption of any chunk without processing preceding chunks:
- No compression: chunk byte offsets are deterministic:
STREAM_HEADER_SIZE + N × (CHUNK_SIZE + STREAM_CHUNK_OVERHEAD), whereSTREAM_CHUNK_OVERHEAD = 17(1tag_byte+ 16 Poly1305 authentication tag) — using the exact names from Appendix A. Full expansion:26 + N × (1,048,576 + 17)=26 + N × 1,048,593. Thetag_byteoccupies the first byte of each chunk at this offset; the AEAD ciphertext (the bytes passed to XChaCha20-Poly1305) starts atSTREAM_HEADER_SIZE + N × STREAM_CHUNK_STRIDE + 1. A seek-and-decrypt implementation that passes the tag_byte as the first byte to the AEAD primitive getsAeadFailedwith no obvious diagnostic. Recommended for random-access use cases (video seeking, resumable downloads). - With compression: chunk sizes are content-dependent. The caller must build a chunk-offset index during encryption (accumulate per-chunk output sizes).
Index integrity: A tampered chunk-offset index (pointing to the wrong byte range for a given chunk index) causes AEAD failure, not silent wrong plaintext — both the per-chunk nonce and AAD include the chunk index, so presenting chunk N's ciphertext at index M fails authentication. This holds for both sequential and random-access modes.
decrypt_chunk_at takes an extracted chunk, not the stream tail: The chunk parameter is exactly one encrypted chunk's bytes — the bytes from the stream at offset 26 + N × 1,048,593 to the start of the next chunk (26 + (N+1) × 1,048,593), excluding the stream header. It is NOT the remaining stream bytes starting at that offset. Passing the stream tail (everything from the chunk's start byte to the end of the stream) does not decrypt correctly — the function expects exactly one chunk and treats trailing bytes as an oversized input that fails length validation. The caller is responsible for extracting the correct byte range before calling decrypt_chunk_at. For uncompressed streams, the offset formula above gives the exact byte range; for compressed streams, the caller must use the chunk-offset index built during encryption (§15.11 "With compression").
The decrypt_chunk_at API accepts a chunk index directly, does not advance the sequential counter, does not set the finalized flag regardless of tag_byte, and can be called on an immutable (&self) reference. Sequential and random-access decryption can be mixed on the same decryptor handle — decrypt_chunk_at never modifies next_index or finalized, so it does not interfere with a sequential pass (e.g., random-access retry of a failed chunk during an otherwise sequential download). "Mixed" means interleaved calls within a single thread, not concurrent multi-threaded access. Parallel chunk decryption requires separate decryptor handles initialized from the same key and header bytes; the CAPI reentrancy guard (§13.6) prevents concurrent calls on the same handle.
*const SolitonStreamDecryptor means "no observable state mutation," not "thread-safe": The CAPI signature uses *const SolitonStreamDecryptor for soliton_stream_decrypt_chunk_at to reflect that the function takes &self in Rust (no state mutation). In C, const T* conventionally signals "safe for concurrent reads," but this guarantee does NOT hold here — the CAPI reentrancy guard (§13.6) fires on any concurrent call regardless of whether the call is read-only. A C binding author who interprets *const as "concurrent calls are safe" and dispatches decrypt_chunk_at from multiple threads on the same handle receives ConcurrentAccess (-18) with no indication that const was the source of confusion. Parallel chunk decryption always requires separate handles, even when all calls are read-side decrypt_chunk_at. The const qualifier signals the Rust-level API contract (immutable borrow), not a C-level concurrency guarantee.
Atomicity: On encryption or decryption failure (AEAD rejection, ChainExhausted, decompression failure, post-finalization guard, Internal from compression expansion check), the encryptor/decryptor state is unchanged — next_index is not advanced and finalized is not set. The operation is retryable (the same chunk can be re-submitted after correcting the input). Unlike ratchet encrypt() (§6.5), per-chunk failures are NOT session-fatal and the streaming key is NOT zeroized on error — retryability requires the key to survive failed calls. The key is zeroized exclusively on handle destruction (soliton_stream_encrypt_free / soliton_stream_decrypt_free). Reimplementers MUST NOT zeroize the streaming key on per-chunk AEAD failure.
Output parameters on error: On any error return from soliton_stream_decrypt_chunk or soliton_stream_decrypt_chunk_at, the output parameters are set to defined values: *out_written = 0 and *is_last = false. Callers MUST check the return code before reading out_written or is_last — on error, these values are sentinels, not results. A caller who reads out_written or is_last without first checking the return code gets 0 and false, which is safe (no buffer overflow, no false finalization signal), but the defined-value guarantee is part of the CAPI contract so reimplementers must provide it. Note: soliton_stream_encrypt_chunk sets only *out_written = 0 on error; there is no is_last output parameter on the encrypt side.
15.12 Stream-Level Security Analysis
Cross-stream splicing: Each stream has a unique random base nonce (24 bytes from CSPRNG). Moving a chunk from stream A into stream B at the same index fails AEAD authentication — the per-chunk nonce is derived from the base nonce, so the chunk decrypts under a different nonce in stream B. Moving a chunk to a different index within the same stream also fails — different chunk index produces a different nonce.
Chunk reordering: Per-chunk nonces are deterministic from (base_nonce, index). Swapping chunks i and j fails AEAD because each chunk authenticates under its own index-derived nonce. The sequential decryptor also detects reordering via next_index monotonic advance. next_index starts at 0 (§15.9) — the first chunk has index 0. A stream encrypted with N chunks has chunk indices 0 through N−1, and next_index equals N after the final chunk is encrypted. Unlike ratchet send_count (which starts at 0 but represents a sequence number where 0 is the first sent message), stream chunk indexing is zero-based purely as a counter: chunk 0 is the first chunk, not a "zeroth" message. Reimplementers who initialize next_index = 1 by analogy with ratchet counters will misalign every chunk's nonce from the first chunk onward.
Truncation: The is_final tag byte (§15.10) detects truncation — a sequential decryptor that reaches EOF without seeing tag_byte = 0x01 knows the stream was truncated. The detection mechanism is is_finalized() == false after transport EOF, not a library error: the library does not return an error for a non-finalized stream at EOF; it returns errors only per-chunk (e.g., AeadFailed for a partial chunk, ChainExhausted for index overflow). Truncation between whole chunks — i.e., the transport closes cleanly without delivering the final chunk — produces no library error on any call. The caller detects this by checking is_finalized() after transport EOF: false means the final chunk (tag_byte = 0x01) was never delivered. A reimplementer who adds a check_complete() or flush() API that returns InvalidData for non-finalized state creates an incompatible API — no such call exists in the reference implementation. Random-access decryptors do not check finalization and cannot detect truncation; callers using random-access mode must verify completeness externally (e.g., via a known chunk count in the stream metadata). For compressed streams, the chunk count is not derivable from byte length (unlike uncompressed streams where (total_bytes - HEADER_SIZE) / (CHUNK_SIZE + CHUNK_OVERHEAD) is exact). The chunk count must be stored in the enclosing metadata. This metadata must itself be authenticated — it must be covered by a ratchet AEAD, a detached signature, or another integrity mechanism. An adversary who controls the metadata channel can substitute a smaller chunk count, making a truncated stream appear complete to a random-access caller that decrypts only the first N chunks. Storing the chunk count in an unauthenticated plaintext field (e.g., a JSON wrapper, an HTTP header) defeats this completeness check entirely. Standard authenticated placement: include the chunk count in the same ratchet message body that delivers the stream key (§15.1). The ratchet AEAD authenticates the entire message body, so the chunk count inherits authentication without a separate integrity mechanism. This is the recommended pattern; alternatives (detached signature, AEAD-authenticated sidecar) are valid but add complexity for no benefit in the standard composition.
Definition of "chunk count": The chunk count is the total number of chunks produced by the encryptor — equivalently, final_chunk_index + 1, where final_chunk_index is the 0-based index of the final chunk (the chunk with tag_byte = 0x01). For a stream with N non-final chunks followed by one final chunk, the chunk count is N + 1. This is also the value of the encryptor's next_index counter immediately after calling encrypt_chunk with is_last = true. An off-by-one (storing final_chunk_index instead of final_chunk_index + 1) silently accepts a stream truncated before the last chunk: a random-access caller decrypting chunks 0 through count − 1 would stop one chunk before the final one, never seeing is_last = true and incorrectly treating the stream as complete.
next_index reliability after a failed encrypt_chunk: This is only reliable when is_finalized() is true. If the final encrypt_chunk(is_last=true) call fails (e.g., InvalidData for oversized plaintext), next_index is not incremented — the state is unchanged (§15.11 atomicity). A caller who reads next_index after a failed final-chunk call and treats it as chunk count will be off by one.
No CAPI function retrieves the chunk count post-finalization: The CAPI provides soliton_stream_decrypt_expected_index (read the decryptor's sequential counter) but no corresponding soliton_stream_encrypt_expected_index. To implement the recommended metadata pattern (§15.12 authenticated chunk count), CAPI callers must track the chunk count themselves — increment a caller-managed counter on each soliton_stream_encrypt_chunk call. soliton_stream_decrypt_expected_index cannot substitute for a caller-managed counter: this function reads the decryptor's own sequential next_index (the number of chunks it has successfully decrypted), not the encryptor's state. A paired decryptor that has not yet decrypted any chunks returns 0 — it has no visibility into how many chunks the encryptor has produced. Do not use soliton_stream_decrypt_expected_index as a proxy for the encryptor's chunk count. The simplest workaround: maintain an application-level chunk_count variable initialized to 0, increment it after each successful soliton_stream_encrypt_chunk, and embed the final value in the ratchet message alongside the stream key. Neither the Rust API nor the CAPI exposes the encryptor's internal index counter — StreamEncryptor provides only header() and is_finalized() (the symmetric expected_index() exists on StreamDecryptor only, as an asymmetric design choice). CAPI callers and Rust callers alike must implement equivalent counter tracking in application code.
Chunk deletion (middle): Deleting chunk i causes chunk i+1 to be presented at index i during sequential decryption — AEAD fails (wrong nonce for that ciphertext). Random-access at the original index returns AeadFailed (no ciphertext at that offset, or wrong ciphertext).
encrypt_chunk_at index uniqueness: Calling encrypt_chunk_at twice with the same index on the same encryptor handle produces identical ciphertext both times — nonce and AAD are deterministic from the index, so the same plaintext at the same index always yields the same encrypted output. This is not a security vulnerability within a single stream (unique indices across chunks prevent nonce reuse between chunks), but it means encrypt_chunk_at offers no write-once enforcement: a caller who accidentally encrypts the same index twice will silently produce a redundant chunk with no error return. The stream assembled from such duplicate calls contains two ciphertexts at the same position; which one the decryptor sees depends on how the caller assembles the stream. Callers using encrypt_chunk_at for parallel encryption MUST ensure each chunk index is used exactly once. The library cannot enforce this — enforcing it would require shared mutable state, which contradicts the &self contract.
Chunk replay: The streaming layer does not provide replay protection — this is mode-dependent. In sequential mode, replaying a chunk that was already successfully decrypted fails incidentally: the sequential decryptor has already advanced next_index past that chunk's position, so presenting the chunk again decrypts it at a different (wrong) index, producing AeadFailed. This is incidental, not by design — the sequential counter provides freshness as a side effect of monotonic advance, not via explicit replay tracking. In random-access mode, decrypting the same (index, chunk) pair twice succeeds both times — decrypt_chunk_at is stateless and has no memory of prior decryptions. A formal modeler asking whether the streaming layer provides authenticated-channel replay resistance gets different answers depending on the mode. Reimplementers MUST NOT add stateful replay tracking to decrypt_chunk_at — its stateless contract is required for mixed sequential/random-access operation (§15.11) and for parallel chunk decryption across multiple handles.
Cross-session replay and key freshness: The in-session counter provides no protection against cross-session replay — presenting an entire stream (header + all chunks) to a fresh decryptor initialized with the same key succeeds without error. The stream has a unique base_nonce, but the decryptor has no memory of prior base nonces. Protection against cross-session replay relies entirely on the key being freshly generated from the OS CSPRNG for each stream (§15.1). The probability that two streams share the same key is negligible with a properly seeded CSPRNG. A reimplementer who derives stream keys deterministically (e.g., from a counter or from fixed material) or who reuses stream keys across sessions loses this protection entirely — cross-session replay becomes trivially possible.
Key reuse across streams: Catastrophic — two streams with the same key and base nonce produce identical per-chunk nonces, enabling XOR of plaintexts. The base nonce is 192 bits from CSPRNG, making accidental collision negligible (~2^-96 birthday bound for 2^48 streams). Callers MUST NOT reuse keys across streams; generate a fresh random key per stream. caller_aad does not substitute for key freshness: using distinct caller_aad values with the same key does not prevent nonce reuse — nonces are derived from the base nonce and chunk index, not from the AAD. Two streams with the same key and same base nonce (birthday collision) produce identical nonces regardless of caller_aad differences. The isolation primitive is the per-stream random key and base nonce, not the AAD.
Appendix A: Constants
All domain labels and AAD prefixes are raw UTF-8 byte strings — no null terminators, no length prefixes. Concatenation with other fields (fingerprints, header bytes, etc.) is raw byte concatenation unless explicitly annotated otherwise (e.g., KEX info uses length-prefixed fields per §5.4).
AUTH_HMAC_LABEL = b"lo-auth-v1" // 10 bytes
KEX_HKDF_INFO_PFX = b"lo-kex-v1" // 9 bytes
SPK_SIG_LABEL = b"lo-spk-sig-v1" // 13 bytes
INITIATOR_SIG_LABEL = b"lo-kex-init-sig-v1" // 18 bytes
RATCHET_HKDF_INFO = b"lo-ratchet-v1" // 13 bytes
DM_AAD = b"lo-dm-v1" // 8 bytes — shared by first-message (§5.4)
// and ratchet-message (§6.5) AAD. Context
// disambiguation is provided by the suffix
// (session-init-bytes vs. ratchet-header-bytes),
// not the label. Cross-context confusion
// (feeding a first-message ciphertext as a
// ratchet message or vice versa) is rejected
// by AEAD: encode_session_init begins with a
// 2-byte BE length prefix (~0x000C) while
// encode_ratchet_header begins with 1216 bytes
// of public key material — the AAD mismatch
// causes tag verification to fail. Future
// message formats needing distinct AEAD contexts
// MUST use a new label.
STORAGE_AAD = b"lo-storage-v1" // 13 bytes
DM_QUEUE_AAD = b"lo-dm-queue-v1" // 14 bytes — separate label (not DM_AAD with a suffix)
// because the DM queue context has no fixed structural
// suffix to provide disambiguation. DM_AAD is shared
// between first-message and ratchet-message contexts
// because those contexts have structurally distinct
// suffixes (session-init bytes vs. ratchet-header bytes)
// that make cross-context confusion impossible. DM queue
// AAD has no such suffix — using DM_AAD with a queue-
// specific suffix would require a separately-standardized
// encoding convention with the same collision-prevention
// burden as a distinct label. A distinct label is simpler.
CALL_HKDF_INFO = b"lo-call-v1" // 10 bytes
PHRASE_HASH_LABEL = b"lo-verification-v1" // 18 bytes
PHRASE_EXPAND_LABEL = b"lo-phrase-expand-v1" // 19 bytes
MSG_KEY_DOMAIN_BYTE = 0x01 // HMAC domain byte for KDF_MsgKey (§6.3)
// 0x02 reserved — gap buffer between 0x01 (message key
// derivation) and 0x03; reserved for hypothetical future
// epoch-key-derived outputs to maintain a consistent gap
// and prevent contiguous assignment with 0x01.
// 0x03 reserved (prevents collision with call chain bytes 0x04-0x06)
CALL_KEY_A_BYTE = 0x04 // HMAC data byte for first call key
CALL_KEY_B_BYTE = 0x05 // HMAC data byte for second call key
CALL_CHAIN_ADV_BYTE = 0x06 // HMAC data byte for next call chain key
MAX_CALL_ADVANCE = 2²⁴ // Maximum advance_call_chain steps per call session
// (16,777,216 rekeys). Exceeding this limit returns
// ChainExhausted. Also listed in Appendix B.
// NOT an exported pub const — this is a private
// const in call.rs; importing MAX_CALL_ADVANCE by
// name will fail at link/import time. Binding authors
// must embed the literal value (16_777_216 / 0x100_0000).
CALL_ID_SIZE = 16 // 128-bit random call identifier
XWING_CIPHERTEXT_SIZE = 1120 // X-Wing KEM ciphertext bytes: X25519_eph_pk (32) ||
// ML-KEM-768_ct (1088), LO X25519-first order (§8.1).
// Fixed in lo-crypto-v1; length-prefixed in wire format
// (§7.4) for forward-compat across crypto versions.
HMAC_SHA3_256_BLOCK_SIZE = 136 // NOT an exported pub const — binding authors must
// embed the value 136 directly; importing this name
// will fail at link/import time.
// SHA3-256's Keccak rate (block size) in bytes.
// RFC 2104 HMAC pads/truncates keys to the hash's
// block size — 136 bytes for SHA3-256, NOT the 64
// bytes of SHA-2. A reimplementer using a SHA-2-
// configured HMAC library or hardcoding 64 as the
// block size produces wrong output on every KDF_MsgKey,
// KDF_Root, KDF_Call, and AdvanceCallChain call.
// Standard HMAC libraries handle this automatically
// when SHA3-256 is selected — this constant exists
// for reimplementers building HMAC from primitives
// and for interoperability test vectors (F.25 / T3).
XWING_SEED_SHAKE_OUTPUT = 96 // NOT an exported pub const — binding authors must
// embed the value 96 directly; importing this name
// will fail at link/import time.
// SHAKE256 output length (bytes) for X-Wing seed expansion
// (§8.5, draft-09 §3.2): SHAKE256(seed_32, 96) → d(32)
// || z(32) || sk_X(32). Not used in production keygen
// (which draws three independent CSPRNG values) — used
// exclusively in deterministic test environments and KAT
// reproduction. A reimplementer using SHAKE256(seed, 64)
// would derive only d and z, missing sk_X.
HKDF_ZERO_SALT = [0x00] × 32 // 32 zero bytes (sequence notation — not integer multiplication)
MAX_RECV_SEEN = 65536 // max entries in recv_seen duplicate tracking set
RATCHET_BLOB_VERSION = 0x01 // current ratchet state serialization version (§6.8).
// `from_bytes` returns `UnsupportedVersion` for any
// version ≠ 0x01. No migration path for unknown versions.
STREAM_HEADER_VERSION = 0x01 // current streaming AEAD header version (§15.2) — Rust source: STREAM_VERSION
CRYPTO_VERSION = "lo-crypto-v1"
XWING_LABEL = 0x5c 0x2e 0x2f 0x2f 0x5e 0x5c // \.//^\ (label goes LAST in combiner)
STREAM_AAD = b"lo-stream-v1" // 12 bytes
STREAM_TAG_NONFINAL = 0x00 // non-final chunk tag byte. Three roles:
// (1) XOR component in nonce derivation (§15.3) —
// XORed into mask byte 8, producing a nonce that
// is distinct from the final-chunk nonce at the
// same index (0x00 vs 0x01 in byte 8).
// (2) Final-chunk signal — value 0x00 means there
// are more chunks to follow; the sequential
// decryptor does not set finalized=true.
// (3) Reader termination — sequential decryptors
// continue reading chunks as long as tag_byte ≠ 0x01.
STREAM_TAG_FINAL = 0x01 // final chunk tag byte. Three roles:
// (1) XOR component in nonce derivation (§15.3) —
// XORed into mask byte 8, producing a nonce that
// differs from the non-final nonce at the same index.
// This prevents the final-chunk ciphertext from being
// presentable as a valid non-final chunk (the nonces
// differ, so AEAD would fail if the tag_byte were flipped).
// (2) Final-chunk signal — exactly one chunk per stream
// has tag_byte=0x01; its presence terminates the stream.
// (3) Reader termination — sequential decryptors set
// finalized=true and reject any subsequent decrypt_chunk
// calls when a chunk with tag_byte=0x01 is successfully
// decrypted.
CHUNK_SIZE = 1_048_576 // plaintext bytes per non-final chunk (1 MiB).
// Also the minimum output buffer size for
// soliton_stream_decrypt_chunk /
// soliton_stream_decrypt_chunk_at (see Appendix B).
// Rust source: STREAM_CHUNK_SIZE (the exported pub
// const is named STREAM_CHUNK_SIZE, not CHUNK_SIZE;
// this spec uses CHUNK_SIZE as the canonical name).
FLAG_COMPRESSED = 0x01 // bits 1-7 reserved (MUST be zero on write,
// collapse to AeadFailed on read per §15.7).
// This flag appears in: §11.1 storage blob header (flags byte),
// §11.2 DM queue blob, §15.2 streaming AEAD header (flags byte),
// §15.5 streaming AAD. In all contexts, bit 0 = compression
// (0 = none, 1 = zstd). Binding authors using the flag
// value directly should define this constant locally.
STREAM_HEADER_SIZE = 26 // bytes in the streaming AEAD header (§15.2):
// version (1) + flags (1) + base_nonce (24).
// Used in the random-access offset formula
// (§15.11): offset = STREAM_HEADER_SIZE + N × STREAM_CHUNK_STRIDE.
STREAM_CHUNK_OVERHEAD = 17 // bytes added per chunk beyond plaintext:
// tag_byte (1) + Poly1305 tag (16).
// An encrypted chunk is: tag_byte (1) ||
// XChaCha20-Poly1305 output (plaintext + 16).
STREAM_CHUNK_STRIDE = 1_048_593 // fixed byte stride between uncompressed chunk
// boundaries: CHUNK_SIZE + STREAM_CHUNK_OVERHEAD
// = 1_048_576 + 17 = 1_048_593.
// Used in the §15.11 random-access offset formula:
// byte_offset(N) = STREAM_HEADER_SIZE + N × STREAM_CHUNK_STRIDE.
// Only valid for uncompressed streams; compressed
// chunk sizes are content-dependent (§15.11).
// NOT an exported pub const — binding authors must
// compute this as CHUNK_SIZE + STREAM_CHUNK_OVERHEAD;
// importing STREAM_CHUNK_STRIDE by name will fail
// at link/import time.
STREAM_ZSTD_OVERHEAD = 256 // zstd expansion guard for streaming encrypt_chunk (§15.11).
// If zstd output exceeds plaintext.len() + 256, encrypt_chunk
// returns Internal (retryable with compress=false). The value
// is a conservative margin: zstd's worst-case expansion on
// incompressible 1 MiB data is ~50 bytes (frame + block headers);
// 256 provides ~5× headroom. Used in STREAM_ENCRYPT_MAX below.
STREAM_ENCRYPT_MAX = 1_048_849 // max bytes of CAPI output buffer for one chunk:
// CHUNK_SIZE (1_048_576) + ZSTD_OVERHEAD (256) +
// CHUNK_OVERHEAD (17). Binding authors MUST
// allocate at least this many bytes for the output
// buffer passed to soliton_stream_encrypt_chunk;
// smaller buffers return InvalidLength.
// NOTE: this is the ceiling for the full-CHUNK_SIZE
// case. For a short final chunk (e.g., 100 bytes
// of plaintext), the Internal guard fires if zstd
// expands that chunk beyond 100 + ZSTD_OVERHEAD
// (= 356 bytes), not beyond STREAM_ENCRYPT_MAX.
// The guard is per-actual-plaintext-length, not
// per-CHUNK_SIZE. An encrypt_chunk caller with a
// 100-byte final chunk needs only a 356-byte CAPI
// output buffer, but MUST still allocate at least
// STREAM_ENCRYPT_MAX to satisfy the length guard.
Appendix B: Parameters
| Parameter | Value |
|---|---|
| OPK batch size | 100 |
| Pre-key low threshold | 10 |
| SPK rotation | 7 days |
| Old SPK retention | 30 days (from rotation, not generation — §10.2) |
| Auth challenge timeout | 30 seconds (§4.4) |
| Max recv_seen entries | 65536 per epoch |
| Max epoch length | 2^32 - 1 messages |
| Storage key versions | 1-255 |
| Verification phrase | 7 words / EFF large wordlist (7,776 words) |
| Verification phrase entropy | ~90.3 bits (7 × log2(7776)) |
| Zstd compression level | Fastest (~1); ruzstd 0.8.x limitation |
| Max plaintext per blob (encrypt) | 256 MiB on native; 16 MiB on WASM — encrypt_blob returns InvalidData if plaintext exceeds the platform limit before compression. This is the caller-provided pre-compression plaintext size, not the post-compression ciphertext size. |
| Max decompressed blob | 256 MiB |
| Call ID size | 16 bytes (128-bit random) |
| Argon2id version | 0x13 (decimal 19 = v1.3, the only version produced and accepted; §10.6) |
| Argon2id m_cost | 8 KiB - 4,194,304 KiB (4 GiB); must be ≥ 8 × p_cost (RFC 9106 §3.1) |
| Argon2id t_cost | 1 - 256 |
| Argon2id p_cost | 1 - 256 |
| Argon2id output length | 1 - 4,096 bytes |
| Argon2id salt minimum | 8 bytes |
Argon2id secret (pepper) |
Empty (0 bytes) — soliton does not use the Argon2id pepper input; reimplementers MUST pass empty secret |
Argon2id ad (associated data) |
Empty (0 bytes) — soliton does not use the Argon2id associated data input; reimplementers MUST pass empty ad |
| Stream chunk size | 1 MiB (1,048,576 bytes) |
| Stream header size | 26 bytes (version + flags + nonce) |
| Stream chunk overhead | 17 bytes (tag_byte + Poly1305 tag) |
| Stream zstd overhead | 256 bytes (~5× worst-case margin; zstd worst-case expansion on incompressible data is ~50 bytes for a 1 MiB input: frame header + block headers) |
| Stream max encrypted chunk | 1,048,849 bytes (CHUNK_SIZE + ZSTD_OVERHEAD + CHUNK_OVERHEAD) |
| Stream decrypt output buffer minimum | 1,048,576 bytes (CHUNK_SIZE) — binding authors MUST allocate at least this many bytes for the output buffer passed to soliton_stream_decrypt_chunk / soliton_stream_decrypt_chunk_at; smaller buffers return InvalidLength regardless of actual plaintext size |
| Stream max chunk index (sequential) | u64::MAX − 1 (guard fires at next_index == u64::MAX) |
| Stream max chunk index (random-access) | u64::MAX (no guard — any u64 accepted, returns AeadFailed for indices no encryptor produced; see §15.9) |
Argon2id OWASP_MIN preset |
m=19 MiB (19456 KiB), t=2, p=1 — interactive auth |
Argon2id RECOMMENDED preset |
m=64 MiB (65536 KiB), t=3, p=4 — stored keypair |
Argon2id WASM_DEFAULT preset |
m=16 MiB (16384 KiB), t=3, p=1 — WASM targets |
| Call chain advance limit | 2²⁴ steps (16,777,216 rekeys per call session, §6.12). step_count starts at 0; the initial keys from derive_call_keys are step-0 keys. ChainExhausted fires when step_count reaches 2²⁴ (i.e., after 16,777,216 advance() calls). |
| WASM decompressed blob limit | 16 MiB (WASM targets use a lower limit than the general 256 MiB; §11.3) |
| Max ratchet serialization epoch | u64::MAX − 1 (epoch u64::MAX triggers ChainExhausted from to_bytes, §6.8) |
| Ratchet blob deserialization cap | 1 MiB (1,048,576 bytes) — CAPI soliton_ratchet_from_bytes / from_bytes_with_min_epoch reject inputs exceeding this size with InvalidLength (-1). Tighter than the general 256 MiB cap; the maximum valid blob is ~530 KB (§6.8). Reimplementers building their own deserialization entry point SHOULD apply an equivalent cap. |
decode_session_init input cap |
64 KiB (65,536 bytes) — soliton_kex_decode_session_init rejects inputs exceeding this size with InvalidLength (-1). Tighter than the general 256 MiB CAPI cap; the maximum valid session init blob is 4,669 bytes (with OPK; per Appendix C / §7.4). |
build_first_message_aad input cap |
8 KiB (8,192 bytes) — soliton_kex_build_first_message_aad rejects session_init_encoded inputs exceeding this size with InvalidLength (-1). The cap is never reached in practice — the maximum valid session_init_encoded blob is 4,669 bytes (with OPK; §7.4 / Appendix C). There is no associated_data parameter on this function. Tighter than the general 256 MiB CAPI cap. |
HKDF Usage Summary
All three HKDF invocations use different salt conventions. Implementers must use the exact salt specified for each KDF — do not assume uniformity.
KDF_Root and KDF_Call share root_key as the HKDF salt — this is safe: both use root_key as the salt, which an auditor might flag as salt reuse. Domain separation is maintained by distinct IKM values (kem_shared_secret for KDF_Root vs kem_ss ‖ call_id for KDF_Call) and distinct info strings ("lo-ratchet-v1" vs "lo-call-v1" ‖ fp_lo ‖ fp_hi). HKDF's Extract step with a shared salt produces different PRKs only when the IKM differs; the different IKM inputs guarantee distinct PRKs. The info strings then provide additional domain separation in the Expand step. Same-salt reuse introduces no cross-context weakness here.
| KDF | Salt | IKM | Info | Output |
|---|---|---|---|---|
| KDF_KEX (§5.4) | 0x00 × 32 (zero salt) |
Combined pre-key shared secrets: 64 B without OPK (ss_ik ‖ ss_spk); 96 B with OPK (ss_ik ‖ ss_spk ‖ ss_opk). The two IKM variants are not interchangeable — a 64 B IKM and a zero-padded 96 B IKM produce different HKDF outputs. |
Length-prefixed composite (§5.4) — exception: the "lo-kex-v1" (9 B) domain prefix is raw, no length prefix (see §5.4 and Appendix A); only the per-field entries that follow it use len(x)‖x encoding |
64 B → (rk, ek) |
| KDF_Root (§6.4) | root_key |
X-Wing shared secret | "lo-ratchet-v1" (raw, 13 B) |
64 B → (rk′, ek′) |
| KDF_Call (§6.12) | root_key |
kem_ss ‖ call_id (raw, 48 B) |
"lo-call-v1" ‖ fp_lo ‖ fp_hi (raw, 74 B) |
96 B → (key_a, key_b, ck) |
Appendix C: Sizes
| Component | Bytes |
|---|---|
| LO composite public key | 3200 — field layout: X-Wing pk (bytes 0-1215) ‖ Ed25519 pk (bytes 1216-1247) ‖ ML-DSA-65 pk (bytes 1248-3199) |
| LO composite secret key | 2496 — field layout: X-Wing sk (bytes 0-2431) ‖ Ed25519 seed (bytes 2432-2463) ‖ ML-DSA-65 seed ξ (bytes 2464-2495) |
| X-Wing public key | 1216 |
| X-Wing secret key | 2432 |
| X-Wing ciphertext | 1120 |
| X-Wing shared secret | 32 |
| X25519 scalar (sk) | 32 |
| X25519 public key | 32 |
ML-KEM-768 public key (ek_PKE) |
1184 |
ML-KEM-768 secret key (expanded, dk_M) |
2400 (see §8.5 for field layout) |
| ML-KEM-768 ciphertext | 1088 |
| ML-KEM-768 shared secret | 32 |
| ML-DSA-65 public key | 1952 |
ML-DSA-65 secret key seed (ξ, stored form) |
32 |
ML-DSA-65 expanded signing key (sk_expanded, not stored — re-derived from seed at signing time per §8.5) |
4032 (FIPS 204 §7.2, ML-DSA-65 sigKeySize) |
| Auth proof / token (HMAC-SHA3-256 output of LO-Auth) | 32 |
| Ed25519 public key | 32 |
| Ed25519 secret key seed (stored form) | 32 |
| Fingerprint (raw) | 32 |
| Fingerprint (hex) | 64 chars |
| Verification phrase | 7 words (~90 bits entropy) |
| Ed25519 signature | 64 |
| ML-DSA-65 signature | 3309 |
| Hybrid signature | 3373 |
| AEAD tag | 16 |
| AEAD nonce | 24 |
| Storage blob header | 26 (version + flags + nonce) |
| Storage blob minimum | 42 (header + Poly1305 tag) |
| Ratchet blob minimum | 195 bytes (§6.8) — any blob shorter MUST be rejected with InvalidData without parsing; see §6.8 for field breakdown |
| Passphrase blob minimum (basic, no prefix) | 56 bytes: salt(16) + nonce(24) + tag(16) for empty plaintext (§10.6) |
| Passphrase blob minimum (basic, with magic prefix) | 57 bytes: 0x00 magic(1) + salt(16) + nonce(24) + tag(16) (§10.6) |
| Passphrase blob minimum (extended, no prefix) | 62 bytes: m_cost(4) + t_cost(1) + p_cost(1) + salt(16) + nonce(24) + tag(16) (§10.6) |
| Passphrase blob minimum (extended, with magic prefix) | 63 bytes: 0x01 magic(1) + m_cost(4) + t_cost(1) + p_cost(1) + salt(16) + nonce(24) + tag(16) (§10.6) |
| Ratchet serialization version | 1 byte (0x01 current) |
| Call ID | 16 |
| Call HKDF output | 96 (send_key + recv_key + chain_key) |
| Call encryption key | 32 |
| Stream header | 26 (version + flags + nonce) |
| Stream chunk overhead | 17 (tag_byte + Poly1305 tag) |
| Stream min valid stream | 43 (header + empty final chunk) |
| Stream max encrypted chunk | 1,048,849 (with zstd overhead) — applies to any chunk, including the final |
| Stream max final chunk plaintext (decrypt output) | 1,048,576 (CHUNK_SIZE) — a final chunk's plaintext is 0..=CHUNK_SIZE bytes; the decrypt output buffer must be at least this size regardless of expected plaintext (§15.6) |
| Stream uncompressed chunk wire stride | 1,048,593 (CHUNK_SIZE + CHUNK_OVERHEAD = 1,048,576 + 17) — the fixed byte stride between chunk boundaries in an uncompressed stream; used by §15.11 random-access offset formula: offset = 26 + N × 1,048,593 |
| encode_session_init (no OPK) | 3,543 — field breakdown (§7.4): 14 (2 len + 12 "lo-crypto-v1") + 32 (sender_ik_fp) + 32 (recipient_ik_fp) + 1216 (sender_ek / X-Wing pk) + 1122 (2 len + 1120 ct_ik) + 1122 (2 len + 1120 ct_spk) + 4 (spk_id u32 BE) + 1 (has_opk=0x00) = 3,543 |
| encode_session_init (with OPK) | 4,669 — adds 1122 (2 len + 1120 ct_opk) + 4 (opk_id u32 BE) = 3,543 + 1,126 = 4,669 (§7.4) |
| encode_ratchet_header (no KEM ct) | 1,225 |
| encode_ratchet_header (with KEM ct) | 2,347 |
| encode_prekey_bundle (no OPK) | 7,808 — 14 (2 len + 12 "lo-crypto-v1") + 3200 (IK_pub) + 1216 (SPK_pub) + 4 (spk_id u32 BE) + 3373 (SPK_sig) + 1 (has_opk=0x00) = 7,808 (§5.3) |
| encode_prekey_bundle (with OPK) | 9,028 — adds has_opk=0x01 (1) + OPK_pub (1216) + opk_id u32 BE (4) = 7,808 − 1 + 1,221 = 9,028 (§5.3) |
| First-message AAD (no OPK) | 3,615 |
| First-message AAD (with OPK) | 4,741 |
| Ratchet message AAD (no KEM ct) | 1,297 |
| Ratchet message AAD (with KEM ct) | 2,419 |
| First-message wire prefix, no OPK (encode + sig) | 6,916 (3,543 + 3,373) |
| First-message wire prefix, with OPK (encode + sig) | 8,042 (4,669 + 3,373) |
| First-message encrypted payload minimum | 40 (nonce + tag) |
| Ratchet ciphertext minimum | 16 (Poly1305 tag only) |
ML-KEM-768 expanded secret key sub-field layout: The 2400-byte dk_M field has four sub-fields whose offsets matter for cross-library interoperability (the dk_PKE sub-field uses NTT-domain encoding, diverging from FIPS 203 coefficient-domain). See §8.5 for the full offset table (dk_PKE at 0, ek_PKE at 1152, H(ek_PKE) at 2336, z at 2368) and the byte-for-byte comparison procedure for detecting encoding incompatibilities.
Appendix D: References
Key Agreement and KEM Protocols
- X3DH: Marlinspike, M. and Perrin, T. "The X3DH Key Agreement Protocol." Signal, 2016. https://signal.org/docs/specifications/x3dh/ — Basis for LO-KEX's asynchronous key agreement design.
- PQXDH: Ehren, S., Gershuni, S., and Perrin, T. "The PQXDH Key Agreement Protocol." Signal, 2023. https://signal.org/docs/specifications/pqxdh/ — Signal's PQ extension of X3DH. LO-KEX uses X-Wing as the sole KEM rather than adding PQ KEM alongside DH.
- Formal Analysis of Signal: Cohn-Gordon, K., Cremers, C., Dowling, B., Garratt, L., and Stebila, D. "A Formal Security Analysis of the Signal Messaging Protocol." Journal of Cryptology, 2020. https://eprint.iacr.org/2016/1013 — The formal analysis LO-KEX should aspire to.
- Modular Double Ratchet: Alwen, J., Coretti, S., and Dodis, Y. "The Double Ratchet: Security Notions, Proofs, and Modularization for the Signal Protocol." EUROCRYPT 2019. https://eprint.iacr.org/2018/1037 — Formal treatment of the Double Ratchet as a composition of CKA and symmetric ratchet. Relevant to LO-Ratchet's KEM-based CKA adaptation.
- CKA Extension: Alwen, J., Coretti, S., Dodis, Y., and Tselekounis, Y. "Security Analysis and Improvements for the IETF MLS Standard for Group Messaging." CRYPTO 2021. https://eprint.iacr.org/2019/1189 — Extends the CKA framework. Relevant to understanding what security properties a KEM-based CKA (like LO-Ratchet's) must satisfy.
- KEM-based X3DH: Brendel, J., Fischlin, M., Günther, F., Janson, C., and Stebila, D. "Towards Post-Quantum Security for Signal's X3DH Handshake." SAC 2020. https://eprint.iacr.org/2020/1353 — Analyzes replacing DH with KEM in X3DH, including authentication asymmetry and IK encapsulation trade-offs.
- Formal Verification of KEM-based AKE: Cremers, C., Jacomme, C., and Lukert, P. "Subgroup-Based Key Agreement Protocols and the Security of KEM-based AKE." CRYPTO 2024. https://eprint.iacr.org/2024/1186 — Recent formal verification methodology for KEM-based key agreement. Relevant approach for future Tamarin/ProVerif analysis of LO-KEX.
- PQ Asynchronous Key Exchange: Hashimoto, K. "Post-Quantum Asynchronous Deniable Key Exchange and the Signal Handshake." PKC 2024. https://eprint.iacr.org/2023/1720 — Deniability and authentication in PQ adaptations of Signal's handshake.
Hybrid Constructions
- Hybrid AKE: Bindel, N., Brendel, J., Fischlin, M., Goncalves, B., and Stebila, D. "Hybrid Key Encapsulation Mechanisms and Authenticated Key Exchange." PQCrypto 2019. https://eprint.iacr.org/2018/903 — Formal treatment of hybrid KEM/AKE, applicable to X-Wing and LO's hybrid signatures.
- Hybrid Signatures: Bindel, N., Herath, U., McKague, M., and Stebila, D. "Transitioning to a Quantum-Resistant Public Key Infrastructure." PQCrypto 2017. https://eprint.iacr.org/2017/460 — Parallel "both must verify" composition (as in LO's Ed25519 + ML-DSA-65) is EUF-CMA secure if either component is.
- KEM Combiners: Giacon, F., Heuer, F., and Poettering, B. "KEM Combiners." PKC 2018. https://eprint.iacr.org/2018/024 — Formal analysis of concatenate-then-KDF for multiple KEMs (relevant to ss_ik || ss_spk || ss_opk derivation).
Component Algorithms
- X-Wing KEM: Connolly, D. et al. draft-connolly-cfrg-xwing-kem-09. https://eprint.iacr.org/2024/039
- X25519 (Diffie-Hellman on Curve25519): Langley, A., Hamburg, M., and Turner, S. "Elliptic Curves for Security." RFC 7748, 2016. https://doi.org/10.17487/RFC7748 — Defines Curve25519 Diffie-Hellman (X25519) as used in the X-Wing classical sub-component (§8). Note §5 of RFC 7748: X25519 implicitly clamps the scalar (bits 0-2 of byte 0 cleared, bit 7 of byte 31 cleared, bit 6 of byte 31 set); the reference implementation relies on this clamping behavior and does not apply it separately. Low-order point handling is described in §6.1 and §8.3.
- ML-KEM: NIST FIPS 203, 2024. https://doi.org/10.6028/NIST.FIPS.203 — The NTT-domain encoding used for the
dk_PKEsub-field of the ML-KEM expanded secret key (§8.5) is defined in FIPS 203 §4.2.1 (NTTfunction) and §4.2.2 (ByteEncode/ByteDecodein NTT representation). Reimplementers investigating the NTT-vs-coefficient divergence should consult these subsections specifically; §7.3 (DecapsKeyGen) defines the key generation procedure but uses coefficient-domain internally beforeByteEncodeis applied. - ML-DSA: NIST FIPS 204, 2024. https://doi.org/10.6028/NIST.FIPS.204
- Ed25519: Josefsson, S., Liusvaara, I. "Edwards-Curve Digital Signature Algorithm (EdDSA)." RFC 8032, 2017. https://doi.org/10.17487/RFC8032
- Double Ratchet: Perrin, T. and Marlinspike, M. "The Double Ratchet Algorithm." Signal, 2016. https://signal.org/docs/specifications/doubleratchet/
Symmetric Primitives
- SHA3-256 and SHAKE256: Dworkin, M. "SHA-3 Standard: Permutation-Based Hash and Extendable-Output Functions." NIST FIPS 202, 2015. https://doi.org/10.6028/NIST.FIPS.202 — Defines the Keccak-based SHA3-256 hash function and the SHAKE256 extendable-output function (XOF). SHA3-256 is used for identity fingerprints, X-Wing combining (§8), HMAC, and HKDF; SHAKE256 is used in X-Wing's ML-KEM-768 seed expansion step (§8.5 —
SHAKE256(seed, 96)expands the 32-byte seed to 96 bytes:d(32) || z(32) || sk_X(32), the ML-KEM-768 generation randomness plus the X25519 secret key). A reimplementer who usesSHAKE256(seed, 64)derives onlydandz, missingsk_X— the X25519 component. The correct length is given in Appendix A (XWING_SEED_SHAKE_OUTPUT = 96). Note the 136-byte block size (rate) of SHA3-256 vs SHA-2's 64-byte block size, relevant for raw HMAC implementation. - HMAC: Krawczyk, H., Bellare, M., and Canetti, R. "HMAC: Keyed-Hashing for Message Authentication." RFC 2104, 1997. https://doi.org/10.17487/RFC2104 — Defines the HMAC construction. For HMAC-SHA3-256, block size is 136 bytes (SHA3-256 rate), not the SHA-2 value of 64 bytes.
- HKDF: Krawczyk, H. and Eronen, P. RFC 5869, 2010. https://doi.org/10.17487/RFC5869
- ChaCha20-Poly1305: Nir, Y., Langley, A. "ChaCha20 and Poly1305 for IETF Protocols." RFC 8439, 2018. https://doi.org/10.17487/RFC8439
- XChaCha20-Poly1305 (HChaCha20 extension): Arciszewski, S. "XChaCha20-Poly1305 Construction." draft-irtf-cfrg-xchacha-03, 2020. https://datatracker.ietf.org/doc/html/draft-irtf-cfrg-xchacha-03 — Defines HChaCha20 (the PRF that extends ChaCha20's 8-byte nonce to 24 bytes). RFC 8439 alone does not define HChaCha20 or XChaCha20; this document is the specification for the 24-byte nonce construction used throughout soliton.
- Nonce Reuse: Joux, A. "Authentication Failures in NIST version of GCM." 2006. — Why AEAD nonce reuse is catastrophic (applies to Poly1305 as well as GCM); motivates LO's defense-in-depth random nonce for first messages.
- Argon2id: Biryukov, A., Dinu, D., Khovratovich, D., and Josefsson, S. "Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications." RFC 9106, 2021. https://doi.org/10.17487/RFC9106 — Password-based key derivation used in §10.6. The Argon2id variant (hybrid of Argon2i and Argon2d) is specified; do not substitute Argon2i or Argon2d.
- Zstandard: Collet, Y. and Kucherawy, M. "Zstandard Compression and the application/zstd Media Type." RFC 8878, 2021. https://doi.org/10.17487/RFC8878 — Compression format used for storage blobs (§11.3) and streaming chunks (§15.5). Pure Rust implementation via the
ruzstdcrate; no dependency on the reference C library. - STREAM: Hoang, V.T., Reyhanitabar, R., Rogaway, P., and Vizár, D. "Online Authenticated-Encryption and its Nonce-Reuse Misuse-Resistance." CRYPTO 2015. https://eprint.iacr.org/2015/189 — Streaming AEAD construction. LO's streaming API uses counter-based nonce derivation (for random access) rather than STREAM's ciphertext chaining.
General
- SoK: Secure Messaging: Unger, N. et al. IEEE S&P 2015. https://doi.org/10.1109/SP.2015.22 — Covers TOFU, forward secrecy, deniability. Useful for positioning LO's design choices.
- Post-Quantum Key Exchange / OQS: Stebila, D. and Mosca, M. "Post-Quantum Key Exchange for the Internet and the Open Quantum Safe Project." SAC 2016. https://doi.org/10.1007/978-3-319-69453-5_2 — Background on post-quantum key exchange design; liboqs originates from this project.
- NIST PQC Standardization: https://csrc.nist.gov/projects/post-quantum-cryptography
- EFF Wordlist: Electronic Frontier Foundation large wordlist for passphrase generation (7,776 words). https://www.eff.org/deeplinks/2016/07/new-wordlists-random-passphrases — The embedded copy is the July 2016 version (108,800 bytes, 7776 lines, LF line endings, dice-number prefix stripped). The hash is computed over the file's raw bytes with LF (
\n) line endings — CRLF-normalized copies have different byte lengths and a different hash. Windows CRLF trap: On Windows withcore.autocrlf=true, Git normalizes LF to CRLF on checkout. After dice-prefix stripping, each line becomesWORD\r— every word gains a trailing carriage return (0x0D). The wordlist hash detects this if verified on the embedded bytes (the embedded CRLF copy produces a different hash), but if stripping and embedding happen at runtime from a file (rather than at build time with a compile-time assertion), the\rappears silently in every word: phrases differ from conforming implementations with no error indicator. Implementations that load the wordlist from a file MUST strip any trailing\r(0x0D) from each line before use, in addition to stripping the dice prefix. SHA3-256 of the raw file:a1e90a00ec269fc42a5f335b244cf6badcf94b62e331fa1639b49cce488c95c5. Reimplementers MUST verify their wordlist matches this hash — different versions or copies of the "EFF large wordlist" produce different phrases for the same indices. Word lookup is case-insensitive; canonical form is lowercase: All words in the embedded wordlist are lowercase ASCII. When looking up a user-entered word (e.g., during phrase verification), comparisons MUST be case-insensitive —"Abacus","ABACUS", and"abacus"all resolve to the same word. The canonical stored form and the form used for index derivation is lowercase. Implementations MUST normalize user input to lowercase before lookup, not expect the user to type in exact case. A case-sensitive comparison would reject correctly-entered phrases from users who capitalize the first word or type in all-caps.
Raw file format and stripping step: Each line in the original EFF file has the format DDDDD\tWORD\n — a 5-digit decimal dice number (e.g., "11111"), a literal tab character (\t), the word (e.g., "abacus"), and a LF newline. Soliton strips the prefix by discarding every character up to and including the first tab on each line, retaining only the word. The resulting embedded wordlist is one word per line with no dice prefix, no tab, and no trailing whitespace. A reimplementer who strips only the digits (not the tab), or who splits on whitespace and takes the last token, produces the same words but must verify against the hash. A reimplementer who takes the first token instead of the last gets the dice number, not the word — a silent interop failure.
Appendix E: Implementor's Guide
This appendix consolidates security-critical requirements scattered throughout the specification into a single reference for binding authors and application developers.
RNG Requirements
All randomness must come from the OS CSPRNG (getrandom on Linux, CryptGenRandom on Windows, SecRandomCopyBytes on macOS/iOS). There is no fallback mechanism — RNG failure is fatal.
The following operations consume randomness:
| Operation | Randomness consumed | Section |
|---|---|---|
generate_identity |
Ed25519 keygen, X25519 keygen, ML-KEM-768 keygen, ML-DSA-65 seed | §2.1, §3.1 |
xwing::keygen |
X25519 keygen, ML-KEM-768 keygen | §2.3 |
xwing::encapsulate |
X25519 ephemeral scalar, ML-KEM-768 encap coins | §2.3 |
HybridSign |
ML-DSA-65 hedged rnd (32 bytes, ephemeral, zeroized after Sign_internal returns — §3.1) |
§3.1 |
encrypt_first_message |
192-bit random nonce | §5.4 |
| KEM ratchet step (send) | xwing::keygen + xwing::encapsulate |
§6.4 |
auth_challenge |
xwing::encapsulate |
§4.2 |
| Call ephemeral KEM | xwing::keygen + xwing::encapsulate |
§6.12 |
stream_encrypt_init |
192-bit random base nonce | §15.1 |
| Stream key (caller) | 256-bit random key (one per stream, MUST NOT be derived from ratchet material) | §15.1 |
Failure Semantics
| Operation | Error | Rollback | State after | Retryable? |
|---|---|---|---|---|
encrypt() |
AEAD failure | Session-fatal. All session keys zeroized as defense-in-depth — a transient AEAD failure followed by retry could produce valid encryption with compromised internal state. Send counter is not incremented (§6.5), but the session is irrecoverable after key zeroization. | Permanently unusable — discard session. | No — new session required. |
decrypt() |
InvalidData (dead session: zeroed root_key) |
Returns before snapshot — no state mutation occurs. | Unchanged. | No — session is permanently dead (post-reset or deserialized from zeroed state). New session required. |
decrypt() |
InvalidData (missing prev_recv_epoch_key) |
Returns before snapshot — no state mutation occurs. | Unchanged. | No — structural error in the message/state combination. |
decrypt() |
ChainExhausted (header n == u32::MAX) |
Returns after snapshot but before any state mutation — rollback is a no-op. The guard is the first operation inside the inner decryption function, before epoch routing, KEM ratchet, or key derivation. The §6.6 pseudocode shows it before the snapshot for presentational clarity; both orderings are correct since no mutations precede the guard. | Unchanged. | No — counter value is inherent to the message. |
decrypt() |
AeadFailed |
Full snapshot/rollback (§6.6). State is restored to pre-decrypt values. | Unchanged. | Yes — caller may retry with different messages. |
decrypt() |
DuplicateMessage |
Full snapshot/rollback (§6.6). Rollback is a no-op: DuplicateMessage can only occur in previous-epoch or current-epoch paths where no state fields are modified before the duplicate check. |
Unchanged. | No — message was already processed. |
decrypt() |
ChainExhausted (recv_seen cap) |
Full snapshot/rollback (§6.6). | Unchanged. | No — epoch's recv_seen set is full (65536 entries). For current-epoch saturation: requires peer to send from a new epoch (one KEM ratchet step = one direction change). For prev_recv_seen saturation: the next KEM ratchet step copies the current recv_seen into prev_recv_seen — if the current set is also full, prev_recv_seen remains saturated after the step. Full recovery from prev_recv_seen saturation may require two direction changes (the first rotates current into previous; the second discards the saturated previous). |
decrypt() |
InvalidData (send_ratchet_sk is None in new-epoch path) |
Returns after snapshot but before any state mutation — rollback is a no-op. The new-epoch path checks send_ratchet_sk presence before performing the KEM ratchet receive step (the else if NOT current_epoch: block in §6.6). |
Unchanged. | No — same message will fail again on any ratchet state (structurally malformed: new-epoch message requires decapsulation with local secret key). |
decrypt() |
InvalidData (kem_ct absent in new-epoch message) |
Returns after snapshot but before any state mutation — rollback is a no-op. | Unchanged. | No — same message will fail again (structurally malformed: new-epoch header lacks KEM ciphertext). |
Streaming AEAD Failure Semantics
Key differences from ratchet encrypt/decrypt:
| Operation | Error | State after | Retryable? |
|---|---|---|---|
encrypt_chunk |
AeadFailed |
Unchanged — next_index not advanced, finalized not set. |
Yes — retry with the same plaintext. Note: in practice, AeadFailed from encrypt_chunk is structurally unreachable — XChaCha20-Poly1305 encrypt can only fail on usize overflow (§7.1), which cannot occur with chunk sizes bounded by CHUNK_SIZE (1,048,576 bytes). If encountered, it indicates an unexpected integer overflow in the AEAD layer, not a transient condition. |
encrypt_chunk |
ChainExhausted (next_index == u64::MAX) |
Unchanged — guard fires before any state mutation. | No — next_index cannot advance further. The handle is not freed; call soliton_stream_encrypt_free. |
encrypt_chunk |
Internal (zstd expansion > STREAM_ZSTD_OVERHEAD) |
Unchanged — guard fires before AEAD. | Yes — retry the same chunk with compress = false. This is not session-fatal; the streaming key is not zeroized. |
encrypt_chunk |
InvalidData (post-finalization or bad chunk size) |
Unchanged. | No — structural caller error. |
decrypt_chunk |
AeadFailed |
Unchanged — next_index not advanced, finalized not set. |
Yes — retry or skip; the decryptor survives. |
decrypt_chunk |
ChainExhausted |
Unchanged. | No — same semantics as encrypt-side. |
decrypt_chunk |
InvalidData (post-finalization, framing, or version mismatch) |
Unchanged. | No. |
Critical differences from ratchet:
- All streaming failures are retry-safe — the streaming key is NEVER zeroized on per-chunk error (unlike ratchet
encrypt(), whereAeadFailedzeroizes all keys and makes the session permanently unusable). Retrying a failed chunk with the correct input will succeed. Internalfrom compression expansion is retryable — passcompress = falsefor the affected chunk. This is an encode-side-only path (no oracle concern); no session state is affected.ChainExhaustedfrom a streaming handle does not affect the ratchet state in any way — the two are independent.
stream_encrypt_free / stream_decrypt_free outer-null behavior differs from ratchet/keyring/callkeys free: soliton_ratchet_free, soliton_keyring_free, and soliton_call_keys_free treat outer-null as a safe no-op (return 0). soliton_stream_encrypt_free and soliton_stream_decrypt_free return NullPointer (-13) for outer-null. The rationale: soliton_stream_encrypt_init and soliton_stream_decrypt_init always write a non-null handle on success — a null outer pointer cannot arise from normal use (init succeeded, producing a valid handle; init failed, leaving the output unchanged). An outer-null pointer to a stream free function signals a caller bug (passing an uninitialized pointer or a wrong variable), whereas a null outer pointer to ratchet/keyring/callkeys free more plausibly arises from defensive cleanup patterns. A reimplementer who makes all free functions return 0 for outer-null will diverge silently; a binding author who expects NullPointer for stream-free outer-null and tests for it will not catch the bug if using the non-streaming free functions.
Caller Obligations
-
Fingerprint → key resolution: The caller is responsible for mapping identity fingerprints to authentic public keys. Incorrect resolution causes §5.5 Step 1 (fingerprint mismatch) or Step 3 (signature verification failure) to fail; the session does not establish silently with a wrong key. The library provides
fingerprint_hex()and verification phrases (§9) but does not manage identity stores, TOFU, or key pinning.receive_sessionfingerprint mismatch returnsInvalidData, notBundleVerificationFailed:receive_sessionis called with a parsedSessionInit(not a bundle), soBundleVerificationFailedis not applicable. A fingerprint mismatch (sender or recipient fingerprint does not match expected values) inreceive_sessionreturnsInvalidData.BundleVerificationFailedapplies only toverify_bundle(§5.3), which also collapses crypto-version mismatches and signature failures toBundleVerificationFailedto prevent an enumeration oracle. Callers who pattern-match on the error fromreceive_sessionexpectingBundleVerificationFailedwill never match it — all fingerprint validation failures fromreceive_sessionarrive asInvalidData. -
OPK deletion: Must happen before the ratchet state is used for messaging (§5.5 Step 4, §10.3). Failure to delete allows an attacker who later compromises the OPK to recover the session key.
-
SPK rotation: 7-day cycle with 30-day grace period for old secret keys (§10.2). Stale SPKs reduce forward secrecy.
-
Secret key zeroization:
IdentitySecretKey,xwing::SecretKey, and shared secrets implementZeroizeOnDropin Rust. CAPI callers must free library-allocated buffers viasoliton_buf_freeand zeroize caller-owned copies of secret material viasoliton_zeroize— standard Cmemsetmay be optimized out. Failing to do either leaks key material. -
Concurrency: All opaque CAPI handles (
SolitonRatchet,SolitonKeyRing,SolitonCallKeys,SolitonStreamEncryptor,SolitonStreamDecryptor) are not thread-safe. Each handle embeds anAtomicBoolreentrancy guard — concurrent calls on the same handle returnConcurrentAccess(-18) rather than corrupting state. This is a diagnostic for caller threading bugs, not a retriable error. Callers must serialize access per handle (e.g., mutex). Concurrent use of different handles is safe.SolitonKeyRingis particularly deceptive: a server encrypting storage blobs for multiple users might naturally share a single keyring across threads, but this will triggerConcurrentAccess. Create one keyring per thread instead. -
Storage decompression: The 256 MiB decompression limit (§11.3) is enforced internally. Callers need not guard against zip bombs.
-
Stream key freshness: Each stream key MUST be freshly generated from the OS CSPRNG (
random_bytes(32)). Do not derive stream keys from ratchet material (epoch key, root key, call key) — a ratchet epoch compromise would propagate to all streams whose keys were derived from the compromised epoch, defeating per-stream isolation. The standard composition: generate a random key, encrypt the stream, then transmit the key inside a ratchet-encrypted message alongside stream metadata (§15.1). -
Auth shared-secret zeroization: The shared secret returned by
auth_respond(§4.2) must be zeroized immediately after use. In Rust,auth_respondreturnsZeroizing<[u8; 32]>(automatic). CAPI callers receive the shared secret in a caller-provided buffer and must callsoliton_zeroizeon it after consuming the value. Failure to zeroize leaves the authentication shared secret on the heap, recoverable via memory scanning. -
Argon2id parameter coupling:
m_costmust be ≥ 8 ×p_cost(RFC 9106 §3.1). This constraint is enforced at the library level —soliton_argon2idreturnsInvalidDatafor combinations wherem_cost < 8 × p_cost(e.g.,m_cost=100, p_cost=100). Per-parameter range checks (m_cost ∈ [8, 4,194,304],p_cost ∈ [1, 256]) pass individually for such combinations, so a binding author who validates parameters against per-field bounds only will not discover the error until the library call returnsInvalidData. The coupling check must be performed in addition to the individual range checks (§10.6 / Appendix B parameter limits). -
Old SPK secret key zeroization: After the 30-day SPK retention window (§10.2), the old SPK secret key MUST be zeroized and discarded. Retaining old SPK private keys beyond the retention window allows an attacker who later compromises those keys to decrypt sessions established with the corresponding SPK, retroactively breaking forward secrecy for the retention period. The library does not manage SPK lifecycle or trigger zeroization automatically — this is the caller's responsibility. See §10.2 for the rotation schedule.
-
Ephemeral
ek_skzeroization after ratchet init: The X-Wing ephemeral secret key (ek_sk, 2432 bytes) inSolitonInitiatedSessionis passed tosoliton_ratchet_init_aliceand must be zeroized and freed immediately after.soliton_kex_initiated_session_freeperforms both the zeroization and deallocation. Do not retainek_skafterinit_alicereturns — the ratchet has absorbed the public key counterpart (ek_pk); the private key is no longer needed and its continued presence in memory extends the window for side-channel or memory-dump attacks. The same obligation applies ifratchet_init_alicereturns an error: zeroize and freeek_skbefore retrying or cleaning up. See §13.5 for the single-use enforcement note. -
Persistent session deserialization MUST use
from_bytes_with_min_epoch, not barefrom_bytes: When deserializing a persisted ratchet state (§6.8), callers MUST usesoliton_ratchet_from_bytes_with_min_epoch(passing the epoch value stored on the last successfulto_bytescall asmin_epoch). Using barefrom_bytes/soliton_ratchet_from_bytessilently accepts a rolled-back epoch, permanently disabling anti-rollback protection — an attacker who can substitute an earlier blob snapshot will cause message-key replay. Barefrom_bytesis provided for initial deserialization only (when no prior epoch exists — specifically, when the application has never successfully completed ato_bytescall on this session and therefore has no persistedmin_epochvalue to supply), not for routine restore-from-disk operations. Binding-layer obligation:soliton_ratchet_from_bytesinsoliton.hhas no deprecation marker in the C header — the deprecation exists only at the Rust API level. Binding authors MUST add a language-level deprecation annotation when wrapping this function (e.g.,@deprecatedin Java/Kotlin,[Obsolete]in C#,#[deprecated]in Rust re-exports, a deprecation warning in Python docstrings) so that callers of the binding receive the same guidance as callers of the Rust API. A session that has been serialized at least once always has amin_epochto supply; that value MUST be stored persistently alongside the encrypted ratchet blob. Losing themin_epochstore (e.g., due to a crash or storage error) does NOT qualify as "no prior epoch" — the correct response is to treat the session as potentially compromised (reset it and re-establish via LO-KEX), not to fall back to barefrom_bytes. Callers who always use barefrom_byteswill never observe an error from anti-rollback rejection, even when a rollback attack is in progress. -
recv_seensaturation recovery requires a peer KEM ratchet step, not local state manipulation: Whendecrypt()returnsChainExhausteddue torecv_seensaturation (65536 entries — §6.8), the correct recovery path is to wait for the peer to send a new message from a new epoch (a KEM ratchet step, triggered by sending in a direction that requires a new ephemeral key). Therecv_seenset resets automatically on the next KEM ratchet step — no explicit caller action is needed. Callers MUST NOT attempt to clear or manipulate therecv_seenset directly; the ratchet state provides no API for this, and the set's integrity is essential for replay protection. An application that interpretsChainExhaustedfromdecrypt()as session-fatal will incorrectly terminate a recoverable session — see §12 for the full per-modeChainExhaustedbreakdown.
Constant-Time Requirements
| Operation | Requirement | Implementation |
|---|---|---|
auth_verify |
Constant-time comparison | subtle::ConstantTimeEq |
| AEAD tag verification | Constant-time | Handled by chacha20poly1305 crate |
hybrid_verify |
Constant-time AND combination of Ed25519 + ML-DSA results | subtle::Choice bitwise AND (§3.2) — short-circuit && leaks which component failed, enabling targeted forgery. Err returns must not cause early exit before both verifications complete: libraries that return Err (rather than Ok(false)) on verification failure (e.g., for malformed-length signatures) must be wrapped so that both components are evaluated before any error is returned — an early ? propagation on the first component's Err skips the second component entirely, leaking which half failed. The reference wraps both calls to produce subtle::Choice values before combining; a reimplementer must apply the same pattern when the underlying library uses error returns rather than boolean-valued verification results. |
| Epoch identification (§6.6) | Constant-time public key comparison | subtle::ConstantTimeEq on ratchet_pk vs recv_ratchet_pk / prev_recv_ratchet_pk — variable-time comparison would leak which epoch a message belongs to (current, previous, or new), revealing ratchet state to a timing attacker |
| Root key liveness check (§6.5, §6.6) | Constant-time all-zero comparison | subtle::ConstantTimeEq — defense-in-depth against partial-zero leakage |
derive_call_keys secret input checks (§6.12) |
Constant-time all-zero comparison for root_key and kem_ss |
subtle::ConstantTimeEq — both are secret material; call_id and fingerprint equality use variable-time == (non-secret public values) |
verify_bundle IK_pub comparison (§5.3) |
Constant-time comparison | subtle::ConstantTimeEq on the full 3200-byte stored identity key vs. bundle.IK_pub — a variable-time comparison leaks the stored key byte-by-byte via response timing (32 × 100-byte probes, each byte determined at the cost of constructing a crafted bundle, far cheaper than paying HybridVerify per probe). verify_bundle collapses all failures to BundleVerificationFailed but does not prevent timing measurements on that common return path. |
receive_session fingerprint checks (§5.5 Step 1) |
Constant-time comparison | subtle::ConstantTimeEq on untrusted wire fingerprints before signature verification — variable-time comparison would let an attacker probe the expected fingerprint byte-by-byte via timing |
| X25519 DH all-zero output check (§8.3) | Constant-time comparison | subtle::ConstantTimeEq against [0u8; 32] — the DH output is secret material before the check fires. A variable-time comparison leaks whether the low-order-point substitution path was taken, revealing a bit of information about the relationship between the ephemeral key and the recipient's public key |
StorageKey::new all-zero key rejection (§11.6) |
Constant-time comparison | subtle::ConstantTimeEq against [0u8; 32] — the key is secret material; variable-time comparison leaks partial information about the key value during the liveness check |
| Streaming layer key quality (§15.1) | No all-zero check — deliberate asymmetry with storage layer. stream_encrypt_init and stream_decrypt_init do NOT validate that the caller-provided key is non-zero. Storage keys are library-managed, long-lived, and validated at construction time (StorageKey::new); streaming keys are ephemeral, caller-provided, and used once — validating them would be a caller-responsibility violation. See §15.1 "All-zero key policy" for full rationale. A reimplementer who adds an all-zero guard to streaming init for consistency with the storage layer diverges from the specification. |
N/A — streaming init does not inspect key quality |
| Stream header version/flags byte comparisons (§15.8) | No constant-time requirement — public values | The version byte and flags byte in the stream header are cleartext, attacker-visible values. Variable-time comparison leaks no information beyond what is already observable from the header bytes themselves. Constant-time comparison (e.g., subtle::ConstantTimeEq) would provide no security benefit here and would add unnecessary complexity. This is in contrast to AEAD tag verification (always CT) and ratchet epoch identification (CT to prevent timing leakage of ratchet state). |
| All other operations | No constant-time requirement at the protocol level | — |
Appendix F: Test Vectors
All values are hex-encoded. These vectors enable a reimplementor to verify their primitive constructions before attempting full protocol integration.
F.1 KDF_MsgKey (§6.5)
epoch_key: 4242424242424242424242424242424242424242424242424242424242424242
counter: 7
HMAC input: 0100000007 (0x01 || BE32(7))
msg_key: cac256e53d0b0abc468331210d63c50f15ec875c3badfef6bfe53e1137165610
Construction: HMAC-SHA3-256(key=epoch_key, data=0x01 || counter_BE32)
Counter = 0 (first message in Bob's initial epoch and in every post-KEM-ratchet epoch for both parties). Alice's first ratchet send uses counter=1, not 0 — her send_count starts at 1 after session initiation (§6.2). See §6.7.1 for a worked example where Alice's first message has n=1.
epoch_key: 4242424242424242424242424242424242424242424242424242424242424242
counter: 0
HMAC input: 0100000000 (0x01 || BE32(0))
msg_key: 5ac7a1b8dd3103a3ef7bab0af995570a087b6a92b34d93bc8c88f3485e96054d
F.2 KDF_Root (§6.4)
root_key (salt): aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
kem_ss (ikm): bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
info: "lo-ratchet-v1"
new_root_key: db7be3c198f86c5e044d6f5c39d526eaf72a651a4cd6b7d32b1adb6b6754d587
new_epoch_key: 71ceff4de7d184f3c97821177dc5afcc2abc334707301c0b9267a3f4b0aa0ff9
Construction: HKDF-SHA3-256(salt=root_key, ikm=kem_ss, info="lo-ratchet-v1", len=64). First 32 bytes = new root key, last 32 bytes = new epoch key.
F.3 X-Wing Combiner (§8.2)
ss_M: 1111111111111111111111111111111111111111111111111111111111111111
ss_X: 2222222222222222222222222222222222222222222222222222222222222222
ct_X: 3333333333333333333333333333333333333333333333333333333333333333
pk_X: 4444444444444444444444444444444444444444444444444444444444444444
label: 5c2e2f2f5e5c
output: 40ad7dbc0dd87305287bd9a9104f5dc064db038a8ac3da443fe3a090a272e2d5
Construction: SHA3-256(ss_M || ss_X || ct_X || pk_X || label). Label goes last (draft-09 §5.3).
pk_X in this vector is a fabricated test value: The pk_X = 0x44...44 input above is not derived from any real X25519 scalar — it is a fixed constant used to verify the SHA3-256 combiner construction independently of X25519 arithmetic. In the actual protocol, pk_X is re-derived during decapsulation as X25519(sk_X, G) (§8.2 and §8.5), where G is the standard X25519 base point: the 32-byte little-endian encoding of the integer 9, i.e., 09 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 (RFC 7748 §6.1). This is the one call to X25519 that is NOT a Diffie-Hellman step — it is a public-key rederivation: X25519(scalar, basepoint). A reimplementer who accidentally uses ct_X (the ephemeral key from the ciphertext, 32 bytes) instead of G (the fixed base point) produces wrong ss_X silently — the combiner runs, AEAD fails, and no diagnostic points to the wrong base point. No fabricated X25519 key-derivation vector is provided here — use the RFC 7748 §6.1 KAT vectors to validate X25519(scalar, basepoint) separately, then combine with this combiner vector to build confidence in the full XWing.Decaps pipeline. Clamping divergence is the primary risk: an implementation that omits RFC 7748 clamping (clear bits 0, 1, 2, 255; set bit 254) before the scalar multiply produces a different output without any error signal — both clamped and unclamped scalars produce valid curve points, just different ones. RFC 7748 §6.1 test vector 1 (u=09...00, k=77...00) exercises the clamped path; compare your X25519(sk_X, G) output against that vector to confirm your library applies clamping before the multiply. §8.5 documents the per-use clamping requirement in detail.
Using draft-09 X-Wing KAT for full pipeline verification: The IETF draft-connolly-cfrg-xwing-kem-09 provides a full X-Wing decapsulation KAT in its Appendix C (SHAKE-256 seed → key generation → encapsulation → decapsulation → shared secret). Applying it to LO requires three adaptations:
- Key ordering: LO uses X25519-first storage (
sk_X (32) || dk_M (2400)for secret key,pk_X (32) || pk_M (1184)for public key); draft-09 uses ML-KEM-first. Extract and reorder components before using draft-09 vectors. - Seed expansion: LO expands the X-Wing seed via
SHAKE-256(seed, 96)→d (32) || z (32) || sk_X (32). Thedandzvalues feed ML-KEM key generation;sk_Xis the X25519 scalar. This matches draft-09 §6.2; verify your SHAKE-256 implementation produces the same intermediate values. - Ciphertext ordering: LO's ciphertext is
ct_X (32) || ct_M (1088)(X25519-first); draft-09's ciphertext isct_M (1088) || ct_X (32). Swap when extracting from draft-09 test vectors. The combiner inputs remain identical once extracted:SHA3-256(ss_M || ss_X || ct_X || pk_X || label).
The source contains an xwing_draft09_decap_kat test that performs exactly this adaptation — use it as a reference for the above steps.
Byte-order swap produces silent wrong output via ML-KEM implicit rejection: If the ciphertext byte order is not adapted (i.e., a draft-09 ML-KEM-first ciphertext ct_M(1088) || ct_X(32) is passed to LO's X25519-first decapsulator as-is), LO extracts the first 32 bytes as ct_X (these are actually the first 32 bytes of ct_M) and the remaining 1088 bytes as ct_M (these are the last 1056 bytes of ct_M concatenated with the actual ct_X). ML-KEM-768 decapsulation of the malformed 1088-byte input does not fail with an error — FIPS 203 defines implicit rejection: when decapsulation fails (ciphertext does not re-encrypt to itself), the function returns a pseudorandom output derived from a pre-keyed hash rather than reporting an error. So ss_M becomes a pseudorandom value, ss_X is computed from wrong bytes, the combiner produces a wrong but plausible-looking 32-byte output, and the AEAD fails with no diagnostic pointing to the byte-order swap. A byte-order-swap bug in a reimplementation is therefore completely invisible until AEAD fails; no intermediate value signals the error. The xwing_draft09_decap_kat test catches this by comparing against the expected shared secret after decapsulation — any byte-order mistake causes a test-vector mismatch at that comparison point.
F.4 Identity Fingerprint (§2.1)
pk: 5555...55 (3200 bytes of 0x55)
fingerprint: 6197102522f51ba35cf4e2e721ffcc5a1ae8e9dc14442b093bc0388696569a4d
Construction: SHA3-256(pk)
F.5 Auth HMAC (§4)
shared_secret: cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc
label: "lo-auth-v1"
hmac_output: b12569ef76edbe2f1215b876d89db5f067bdbf35bd99c6d0bcd47733609f02cf
Construction: HMAC-SHA3-256(key=shared_secret, data="lo-auth-v1"). This vector covers only the HMAC step. The shared secret is the X-Wing combined output (§8.2) from the X-Wing encapsulation/decapsulation in §4.2 — see F.3 for the combiner KAT and §8.2 for the full encap/decap pseudocode.
F.6 Verification Phrase Hash (§9.2)
pk_a: 0101...01 (3200 bytes of 0x01)
pk_b: 0202...02 (3200 bytes of 0x02)
sorted: pk_a first (lexicographic)
hash: 94488b955db55587ef0e0b1721a6db95b62b6f2c61ba158a557a2e007c7638b9
Construction: SHA3-256("lo-verification-v1" || sorted_first || sorted_second)
Word output (from rejection sampling on the hash above):
Samples (u16 big-endian from hash bytes):
[0..2] 0x9448 = 37960 → accepted, 37960 % 7776 = 6856 → "triangle"
[2..4] 0x8b95 = 35733 → accepted, 35733 % 7776 = 4629 → "phobia"
[4..6] 0x5db5 = 23989 → accepted, 23989 % 7776 = 661 → "breeder"
[6..8] 0x5587 = 21895 → accepted, 21895 % 7776 = 6343 → "sterile"
[8..10] 0xef0e = 61198 → accepted, 61198 % 7776 = 6766 → "tibia"
[10..12] 0x0b17 = 2839 → accepted, 2839 % 7776 = 2839 → "gerbil"
[12..14] 0x21a6 = 8614 → accepted, 8614 % 7776 = 838 → "caption"
Phrase: "triangle phobia breeder sterile tibia gerbil caption"
All 7 samples accepted with no rejections (no rehash needed). This vector verifies the full pipeline: hash → u16 extraction → rejection sampling → modular index → EFF wordlist lookup. Note: Neither this vector nor the "with rejection" vector below exercises the rehash path (§9.2: when all 16 u16 samples from a 32-byte hash are exhausted before producing 7 accepted words, compute SHA3-256("lo-phrase-expand-v1" || round || hash) and continue sampling from the new hash). Both vectors complete within the first hash. The rehash path is tested by unit tests with adversarial inputs; a KAT vector is impractical because no naturally-occurring fingerprint pair is known to require rehashing (the probability of exhausting 16 samples is approximately (1 − 62208/65536)^16 ≈ 2 × 10⁻²¹).
With rejection (exercises cursor-advance-on-reject behavior):
pk_a: 0808...08 (3200 bytes of 0x08)
pk_b: 0101...01 (3200 bytes of 0x01)
sorted: pk_b first (lexicographic: 0x01 < 0x08)
hash: 9ea8205db7552a2a0679fbe6760b49fb59b46559ea3a44708ec7feb19b1c8d85
Samples (u16 big-endian from hash bytes):
[0..2] 0x9ea8 = 40616 → accepted, 40616 % 7776 = 1736 → "despise"
[2..4] 0x205d = 8285 → accepted, 8285 % 7776 = 509 → "barrier"
[4..6] 0xb755 = 46933 → accepted, 46933 % 7776 = 277 → "approve"
[6..8] 0x2a2a = 10794 → accepted, 10794 % 7776 = 3018 → "grinch"
[8..10] 0x0679 = 1657 → accepted, 1657 % 7776 = 1657 → "degrading"
[10..12] 0xfbe6 = 64486 → REJECTED (≥ 62208), cursor advances to [12]
[12..14] 0x760b = 30219 → accepted, 30219 % 7776 = 6891 → "tropical"
[14..16] 0x49fb = 18939 → accepted, 18939 % 7776 = 3387 → "implosive"
Phrase: "despise barrier approve grinch degrading tropical implosive"
This vector exercises the critical cursor-advance-on-reject behavior: sample [10..12] (0xfbe6 = 64486) fails the < 62208 acceptance check and is discarded. The cursor advances past it to [12..14] — it does NOT retry at [10..12]. A reimplementer who advances the cursor only on accepted samples would read [10..12] as the 6th accepted sample and produce a different phrase. The rejection also means 8 u16 samples are consumed for 7 words (16 bytes of the 32-byte hash).
F.7 Ratchet Nonce from Counter (§6.5)
counter: 42
nonce: 000000000000000000000000000000000000000000000000002a (24 bytes: zeros with BE32(42) in bytes 20..24)
Counter occupies the last 4 bytes of a 24-byte nonce buffer. Bytes 0-19 are zero.
Counter = 0 (first message of each epoch and of every post-KEM-ratchet epoch):
counter: 0
nonce: 000000000000000000000000000000000000000000000000 (24 bytes: all zero)
An all-zero nonce is valid — see §7.2 for rationale. A reimplementer who guards against all-zero nonces as a "sanity check" would incorrectly reject every epoch's first message.
Counter = 1 (second message, validates that counter increments appear at the correct byte positions):
counter: 1
nonce: 000000000000000000000000000000000000000000000001 (24 bytes: zeros with BE32(1) in bytes 20..24)
The difference between counter=0 and counter=1 is a single bit flip at byte 23 (the least-significant byte of the 4-byte big-endian counter). A reimplementer whose counter goes in bytes 0-3 (wrong end) instead of bytes 20-23 would produce 01000000...00 for counter=1 — the counter=1 vector catches this.
F.8 Storage AAD Construction (§11.4.1)
version: 01
flags: 00 (uncompressed)
channel_id: "general" (67656e6572616c)
segment_id: "2024-03-15" (323032342d30332d3135)
aad: 6c6f2d73746f726167652d76310100000767656e6572616c000a323032342d30332d3135
(36 bytes)
Construction: "lo-storage-v1" || version || flags || len(channel_id) || channel_id || len(segment_id) || segment_id. Length prefixes are 2-byte big-endian.
F.9 Streaming Nonce Derivation (§15.3)
base_nonce: 101112131415161718191a1b1c1d1e1f2021222324252627
chunk_index=0, tag_byte=0x00 (non-final):
mask: 000000000000000000000000000000000000000000000000
chunk_nonce: 101112131415161718191a1b1c1d1e1f2021222324252627
chunk_index=2, tag_byte=0x00 (non-final):
mask: 000000000000000200000000000000000000000000000000
chunk_nonce: 101112131415161518191a1b1c1d1e1f2021222324252627
chunk_index=0, tag_byte=0x01 (final — single-chunk stream):
mask: 000000000000000001000000000000000000000000000000
chunk_nonce: 101112131415161719191a1b1c1d1e1f2021222324252627
chunk_index=2, tag_byte=0x01 (final):
mask: 000000000000000201000000000000000000000000000000
chunk_nonce: 101112131415161519191a1b1c1d1e1f2021222324252627
Construction: mask = chunk_index(u64 BE) || tag_byte || 0x00*15, chunk_nonce = base_nonce XOR mask.
The pair (chunk_index=2, tag_byte=0x00) and (chunk_index=2, tag_byte=0x01) verifies that tag_byte participates in the XOR: the two nonces differ only at byte 8 (0x18 vs. 0x19), which is exactly where tag_byte sits in the mask. An implementation that omits tag_byte from the mask (or passes it as a constant) would compute the same nonce for both entries — the distinct expected values catch this silently.
The (chunk_index=0, tag_byte=0x01) entry covers the single-final-chunk case — the most common real-world usage for small files. An implementation that XORs tag_byte at the wrong byte position in the mask produces the correct nonce for (0, 0x00) (mask is all-zeros regardless) but the wrong nonce for (0, 0x01) (where the tag byte's position within the zero-index mask is the only distinguishing feature). Without this vector, a mask-ordering bug would pass all other F.9 entries.
Debugging property at chunk_index=0, tag_byte=0x00: The mask is all-zeros, so chunk_nonce == base_nonce exactly. If decryption fails for index-0, the bug is in header parsing or base_nonce extraction — not in the XOR step (which is a no-op at this index). If index-0 succeeds but a subsequent index fails, the bug is in the mask construction or XOR logic.
chunk_index = u64::MAX boundary vector: Detects 32-bit-truncation bugs — implementations using a u32 counter silently compute a different nonce for any chunk index above u32::MAX = 0xFFFFFFFF. At u64::MAX, bytes 0-7 of the mask are all 0xFF (vs. only bytes 4-7 for u32::MAX), so the two implementations produce nonces that differ in bytes 0-3.
chunk_index=u64::MAX (0xFFFFFFFFFFFFFFFF), tag_byte=0x00 (non-final):
mask: ffffffffffffffff00000000000000000000000000000000
chunk_nonce: efeeedecebeae9e818191a1b1c1d1e1f2021222324252627
chunk_index=u64::MAX (0xFFFFFFFFFFFFFFFF), tag_byte=0x01 (final):
mask: ffffffffffffffff01000000000000000000000000000000
chunk_nonce: efeeedecebeae9e819191a1b1c1d1e1f2021222324252627
Computed using the same base_nonce = 101112...27 as above: bytes 0-7 XOR 0xFF each; byte 8 XOR tag_byte; bytes 9-23 unchanged.
F.10 Streaming AAD Construction (§15.4)
version: 01
flags: 00 (uncompressed)
base_nonce: 101112131415161718191a1b1c1d1e1f2021222324252627
caller_aad: "" (empty)
chunk_index=0, tag_byte=0x00:
aad: 6c6f2d73747265616d2d76310100101112131415161718191a1b1c1d1e1f2021222324252627000000000000000000
(47 bytes)
chunk_index=0, tag_byte=0x00, caller_aad="file-abc-123":
aad: 6c6f2d73747265616d2d76310100101112131415161718191a1b1c1d1e1f202122232425262700000000000000000066696c652d6162632d313233
(59 bytes)
chunk_index=2, tag_byte=0x01, caller_aad="file-abc-123":
aad: 6c6f2d73747265616d2d76310100101112131415161718191a1b1c1d1e1f202122232425262700000000000000020166696c652d6162632d313233
(59 bytes)
Construction: "lo-stream-v1" || version || flags || base_nonce || chunk_index(u64 BE) || tag_byte || caller_aad.
Why three entries: The first entry (non-final, empty AAD) and third entry (final, non-empty AAD) do not cover the combination non-final + non-empty AAD. A reimplementer who adds a spurious length prefix to caller_aad only when tag_byte == 0x01 (final) passes entries 1 and 3 but fails entry 2. Entry 2 catches this bug.
Note: F.9 and F.10 cover only flags=0x00 (uncompressed). A compressed vector (flags=0x01) exercising the zstd-before-AEAD encrypt path and post-AEAD-decompress decrypt path is needed for complete cross-implementation validation of the compression integration. The AAD construction is identical (only the flags byte differs); the key difference is the plaintext/ciphertext relationship (compressed vs. raw).
F.11 KDF_KEX Session Key Derivation (§5.4 Step 4)
ss_ik: 1111111111111111111111111111111111111111111111111111111111111111
ss_spk: 2222222222222222222222222222222222222222222222222222222222222222
(no OPK)
ikm: 11111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222
(64 bytes: ss_ik || ss_spk)
salt: 0000000000000000000000000000000000000000000000000000000000000000
alice_ik_pub: 0xAA repeated × 3200 bytes
bob_ik_pub: 0xBB repeated × 3200 bytes
ek_pub: 0xCC repeated × 1216 bytes
crypto_version: "lo-crypto-v1" (6c6f2d63727970746f2d7631)
info (7645 bytes): "lo-kex-v1" // 9 bytes raw
|| 000c 6c6f2d63727970746f2d7631 // len(cv) + crypto_version
|| 0c80 [AA × 3200] // len(alice_ik) + alice_ik_pub
|| 0c80 [BB × 3200] // len(bob_ik) + bob_ik_pub
|| 04c0 [CC × 1216] // len(ek) + ek_pub
root_key: 5067b4b2c0b33aafa8be7805a7b1a136c32e7769624b8e78cc762c6194a3322c
epoch_key: 4ee99ff8ff9588a8c1df8819cb0bd49bd39277412f668c6be4ea0850220e8000
Construction: HKDF-SHA3-256(salt=0x00{32}, ikm=ss_ik||ss_spk, info=info, len=64). First 32 bytes = root_key, last 32 bytes = epoch_key. The info field uses 2-byte BE length prefixes for all components. The "lo-kex-v1" prefix is raw (not length-prefixed).
Missing intermediate checkpoint: No SHA3-256 hash of the assembled 7645-byte info field is provided. The info field mixes raw and length-prefixed fields in a specific order; a field-encoding error (wrong prefix size, swapped field order, missing length prefix on "lo-kex-v1") shifts all subsequent bytes and produces a final root_key/epoch_key mismatch with no intermediate signal. A reimplementer can verify their info assembly independently: compute SHA3-256 of the assembled info bytes before passing to HKDF and compare against a trusted build — this check is not provided in-document but is the diagnostic step to run when F.11/F.12 root_key or epoch_key diverge.
F.12 KDF_KEX with OPK (§5.4 Step 4)
ss_ik: 1111111111111111111111111111111111111111111111111111111111111111
ss_spk: 2222222222222222222222222222222222222222222222222222222222222222
ss_opk: 3333333333333333333333333333333333333333333333333333333333333333
ikm: ss_ik || ss_spk || ss_opk (96 bytes)
salt, info: identical to F.11
root_key: c308b84238e8b73424b88d5e24ac6e4e0e5a0bfe047b5620fc9811f368ec0be1
epoch_key: 35d3ddd0b464faa3663e92041cebf2bcd8db593b5b0ebae75e7f02a24631ea2c
The IKM concatenation order (ss_ik || ss_spk || ss_opk) is critical — any reordering produces a different session key.
F.13 encode_session_init (§7.4)
crypto_version: "lo-crypto-v1" (6c6f2d63727970746f2d7631)
sender_ik_fingerprint: 0xAA × 32
recipient_ik_fingerprint: 0xBB × 32
sender_ek: 0xCC × 1216
ct_ik: 0x11 × 1120
ct_spk: 0x22 × 1120
spk_id: 0x000000DD (u32 big-endian)
has_opk: 0x00 (absent)
Encoded layout (3543 bytes):
[0..2] 000c len(crypto_version)
[2..14] 6c6f2d63727970746f2d7631 crypto_version
[14..46] AA × 32 sender_ik_fingerprint (no length prefix — fixed size)
[46..78] BB × 32 recipient_ik_fingerprint (no length prefix — fixed size)
[78..1294] CC × 1216 sender_ek (no length prefix — fixed size)
[1294..1296] 0460 len(ct_ik) = 1120
[1296..2416] 11 × 1120 ct_ik
[2416..2418] 0460 len(ct_spk) = 1120
[2418..3538] 22 × 1120 ct_spk
[3538..3542] 000000dd spk_id (u32 big-endian, no length prefix)
[3542] 00 has_opk
SHA3-256(encoded): e45e05fb2d4218d1cd2f660491cd026ceec187ea7e3048908aa0f37681c36a9c
The SHA3-256 hash provides a quick verification that the encoding is correct — compute the hash of your serialized output and compare before attempting signature or AAD construction. Note: spk_id is a 4-byte big-endian u32 (not 32 bytes), and it follows ct_spk (not sender_ek). The has_kem_ct field does not exist in encode_session_init — it is part of encode_ratchet_header only.
With OPK (4669 bytes):
(Same fields as above through spk_id)
ct_opk: 0x33 × 1120
opk_id: 0x000000EE (u32 big-endian)
Encoded layout (4669 bytes):
[0..3542] (identical to no-OPK variant through spk_id)
[3542] 01 has_opk
[3543..3545] 0460 len(ct_opk) = 1120
[3545..4665] 33 × 1120 ct_opk
[4665..4669] 000000ee opk_id (u32 big-endian)
SHA3-256(encoded): 230d711bebc95875ee9d7e3bd4a56c0cf7e5f34a52a453ec498326b489af7dcc
The OPK block format is: 0x01 || len(ct_opk) || ct_opk || opk_id. Note that opk_id follows ct_opk (not spk_id) — the two key IDs are not adjacent. A reimplementer who places opk_id immediately after spk_id (before ct_opk) will produce an incompatible encoding.
Missing rejection vectors for decode_session_init: No negative-case KATs are provided for decode_session_init. The following inputs MUST return InvalidData and are the primary decoder-strictness checks: (1) has_opk = 0x02 (any byte other than 0x00/0x01 for the OPK flag); (2) trailing bytes after the last field (a conforming no-OPK blob with one extra byte appended); (3) mid-ciphertext truncation (e.g., 3541 bytes — truncated one byte before the end of ct_spk). A decoder that accepts (1) passes format-malleability; one that accepts (2) creates a decoding oracle; one that silently succeeds on (3) produces a ct_spk/spk_id parsing shift. These rejection behaviors are normative requirements in §7.4 but are not covered by existing test vectors.
F.14 encode_ratchet_header (§7.4)
Without KEM ciphertext (same-chain message, 1225 bytes):
ratchet_pk: 0xAA × 1216
has_kem_ct: 0x00 (absent)
n: 42 (0x0000002A)
pn: 10 (0x0000000A)
Encoded layout:
[0..1216] AA × 1216 ratchet_pk (no length prefix — fixed size)
[1216] 00 has_kem_ct
[1217..1221] 0000002a n (u32 big-endian)
[1221..1225] 0000000a pn (u32 big-endian)
SHA3-256(encoded): 71d0bf62f50a1fff7b27b0825426e3ae29b52e2e335940caeb46a485ec73e1bf
With KEM ciphertext (new-epoch message, 2347 bytes):
ratchet_pk: 0xAA × 1216
has_kem_ct: 0x01 (present)
kem_ct: 0xBB × 1120
n: 42 (0x0000002A)
pn: 10 (0x0000000A)
Encoded layout:
[0..1216] AA × 1216 ratchet_pk (no length prefix — fixed size)
[1216] 01 has_kem_ct
[1217..1219] 0460 len(kem_ct) = 1120
[1219..2339] BB × 1120 kem_ct
[2339..2343] 0000002a n (u32 big-endian)
[2343..2347] 0000000a pn (u32 big-endian)
SHA3-256(encoded): 99588b3b8b7539dc864443b16741f642a963207b66eb59058fe5f1729b180ed2
F.15 KDF_Call (§6.12)
root_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
kem_ss: bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
call_id: cccccccccccccccccccccccccccccccc
local_fp: 1111111111111111111111111111111111111111111111111111111111111111
remote_fp: 2222222222222222222222222222222222222222222222222222222222222222
ikm: kem_ss || call_id (48 bytes, no length prefixes)
info: "lo-call-v1" || fp_lo || fp_hi (74 bytes)
fp_lo = local_fp (0x11... < 0x22...), fp_hi = remote_fp
key_a: ed75d812373c9b3bf6bddd394a631950520503f103b492fb908621eb712b5970
key_b: c3e5171534e0d1f922ea4ebf318357b990eafb0fff45d8cf430639a1fe2bb1e4
chain_key: 1427dde311aaa195b116cc98c870753179297981446d3b53e00a4a92a0d34aeb
Construction: HKDF-SHA3-256(salt=root_key, ikm=kem_ss||call_id, info="lo-call-v1"||fp_lo||fp_hi, len=96). Fingerprints sorted by unsigned byte-by-byte lexicographic comparison (lower first). Output: first 32 bytes = key_a, next 32 = key_b, last 32 = chain_key.
Reversed-order coverage: In this vector, local_fp (0x11...) < remote_fp (0x22...) so local_fp is fp_lo and remote_fp is fp_hi. To test the reversed sort branch (where local_fp > remote_fp), swap the two fingerprints: set local_fp = 0x22... and remote_fp = 0x11.... The info field must be identical (sorting produces the same fp_lo = 0x11..., fp_hi = 0x22...), so key_a, key_b, and chain_key are the same as above — but the role assignment reverses: the party whose local_fp = 0x22... now uses key_b as send key (not key_a), because they are the lexicographically higher party. A reimplementer who tests only the non-reversed case passes this vector and misses a sort-direction bug that would produce incompatible role assignments.
F.16 AdvanceCallChain (§6.12)
chain_key: 1427dde311aaa195b116cc98c870753179297981446d3b53e00a4a92a0d34aeb
key_a': 9cf3129c6bb7ad86cb12ffc534517a4c06a472fbcddbe295a501c79aa49800e1
key_b': f24cd7822fd611159a6e6d809c6ac148fd7b9bad65d8b4f85745869634b2dd1e
chain_key': d3ae610c39cd9f7f8dce990b5c91634092ad0621fc01b44b24b2cb9f3638d0f2
Construction: key_a' = HMAC-SHA3-256(chain_key, 0x04), key_b' = HMAC-SHA3-256(chain_key, 0x05), chain_key' = HMAC-SHA3-256(chain_key, 0x06). Each HMAC input is a single byte.
F.17 DM Queue AAD (§11.4.2)
version: 01
flags: 00 (uncompressed)
recipient_fp: 0xAA × 32
batch_id: "batch-001" (62617463682d303031)
aad: 6c6f2d646d2d71756575652d763101000020aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa000962617463682d303031
(61 bytes)
Construction: "lo-dm-queue-v1" || version || flags || len(recipient_fp) || recipient_fp || len(batch_id) || batch_id. Note: len(recipient_fp) = 0020 (2-byte BE for 32 bytes) — recipient_fp is length-prefixed despite being fixed-size, for wire format consistency with community AAD (§11.4.1).
F.18 First-Message AAD (§5.4 Step 7)
sender_fp: 0xAA × 32 (raw SHA3-256, not hex)
recipient_fp: 0xBB × 32
session_init: F.13 no-OPK encoding (3543 bytes)
aad = "lo-dm-v1" || sender_fp || recipient_fp || encode_session_init(si)
(3615 bytes: 8 + 32 + 32 + 3543)
SHA3-256(aad): 091a81dbff776e4a81d34ce22f7cd7efeaf225cd40bbf5f9f49825fd5c462ac7
The sender and recipient fingerprints are the raw 32-byte SHA3-256 digests (§2.1), not hex strings. On the decrypt side (§5.5 Step 6), the AAD is assembled as "lo-dm-v1" || Alice.fingerprint_raw || Bob.fingerprint_raw || session_init_bytes — the same order. This is the most common first integration failure: passing hex-encoded fingerprints (64 bytes) instead of raw (32 bytes), or swapping sender/recipient positions.
F.19 Ratchet Message AAD (§6.5)
sender_fp: 0xAA × 32 (raw SHA3-256, not hex)
recipient_fp: 0xBB × 32
ratchet_header: F.14 no-KEM-ct encoding (1225 bytes)
aad = "lo-dm-v1" || sender_fp || recipient_fp || encode_ratchet_header(h)
(1297 bytes: 8 + 32 + 32 + 1225)
SHA3-256(aad): eaec65b7ac6d8e3912bacf1ed40429ab5005f33550c1d6e0231844fecac6a93e
Direction asymmetry: When Alice encrypts, sender_fp = Alice.fingerprint_raw and recipient_fp = Bob.fingerprint_raw. When Bob decrypts the same message, he assembles AAD with sender_fp = Alice.fingerprint_raw (the message author) and recipient_fp = Bob.fingerprint_raw (himself) — the same order. A reimplementer who uses (local_fp, remote_fp) for both encrypt and decrypt gets the wrong AAD on one side.
F.19b Ratchet Message AAD — New Epoch (§6.5)
sender_fp: 0xAA × 32 (raw SHA3-256, not hex)
recipient_fp: 0xBB × 32
ratchet_header: F.14 with-KEM-ct encoding (2347 bytes)
aad = "lo-dm-v1" || sender_fp || recipient_fp || encode_ratchet_header(h)
(2419 bytes: 8 + 32 + 32 + 2347)
SHA3-256(aad): 25e46f405c91fb21aef5f7cd719d19b36d3edc030edaedf488f5624c02e4c854
This is the most common reimplementation failure path — the with-KEM-ct header (2347 bytes) appears in every new-epoch message, and incorrect KEM ciphertext length-prefix encoding (e.g., omitting it, using little-endian, or using the wrong ciphertext size) silently produces a different AAD hash with no diagnostic.
F.20 Argon2id (§10.6)
password (21 bytes): 746573742d70617373776f72642d736f6c69746f6e ("test-password-soliton")
salt (16 bytes): 736f6c69746f6e2d73616c742d766563 ("soliton-salt-vec")
m_cost: 65536 (KiB, = Argon2Params::RECOMMENDED)
t_cost: 3
p_cost: 4
version: 0x13 (19, Argon2id v1.3)
output_len: 32
output (32 bytes): 79f1dce60c8371a21f849470848c40dc1589deb5119cd3c4f26298c3f17ac3cf
Verified against the reference C implementation (argon2 CLI, libargon2 20190702) and the argon2 Rust crate used by soliton. Key implementation pitfall: m_cost is in KiB (not bytes); passing 65536 bytes instead of 65536 KiB produces a different output silently (Argon2 accepts any m_cost ≥ 8 × p_cost). The p_cost parameter specifies lanes (degree of parallelism); some wrappers accept a separate "threads" parameter — for this vector, lanes = threads = 4.
F.21 Ratchet Blob Layout (§6.8)
The ratchet blob is too large for a full hex dump (3,847 bytes for Alice's minimal initial state due to X-Wing keys) but the structural layout is the primary reimplementation hazard. This annotated offset table describes Alice's initial state after init_alice + one to_bytes() call, with no OPK, no previous epoch, and no recv_seen entries:
Offset Size Field Value (this vector)
------ ------ ----------------------------- -------------------
0 1 version 0x01
1 8 epoch (u64 BE) 1
9 32 root_key [session-dependent]
41 32 send_epoch_key [session-dependent]
73 32 recv_epoch_key 0x00 * 32 (always all-zero for Alice initial — hard-coded in init_alice; not session-dependent)
105 32 local_fp SHA3-256(Alice.IK_pub)
137 32 remote_fp SHA3-256(Bob.IK_pub)
--- Optional: send_ratchet_sk (present) ---
169 1 present flag 0x01
170 2 length (u16 BE) 0x0980 (2432)
172 2432 X-Wing secret key [session-dependent]
--- Optional: send_ratchet_pk (present) ---
2604 1 present flag 0x01
2605 2 length (u16 BE) 0x04C0 (1216)
2607 1216 X-Wing public key [session-dependent]
--- Optional: recv_ratchet_pk (absent in Alice initial) ---
3823 1 present flag 0x00
--- Optional: prev_recv_epoch_key (absent) ---
3824 1 present flag 0x00
--- Optional: prev_recv_ratchet_pk (absent) ---
3825 1 present flag 0x00
--- Counters ---
3826 4 send_count (u32 BE) 0x00000001 (1)
3830 4 recv_count (u32 BE) 0x00000000 (0)
3834 4 prev_send_count (u32 BE) 0x00000000 (0)
--- Flags ---
3838 1 ratchet_pending 0x00
--- recv_seen set ---
3839 4 num_recv_seen (u32 BE) 0x00000000 (0)
(entries would follow as u32 BE, sorted ascending)
--- prev_recv_seen set ---
3843 4 num_prev_recv_seen (u32 BE) 0x00000000 (0)
(entries would follow as u32 BE, sorted ascending)
Total: 3847 bytes
Key reimplementation hazards:
- Optional field encoding: present fields use
0x01 + u16_BE_length + data; absent fields use a single0x00byte. Exception:prev_recv_epoch_keyuses0x01 + 32_bytes(no length prefix) since the size is always exactly 32 bytes. Present-case byte sequence:01 XX XX...XX(33 bytes, whereXX × 32is the key). A decoder that inserts a 2-byte length prefix after the0x01marker misaligns all subsequent fields by 2 bytes. See §6.8 for a full worked example. - X-Wing key sizes: secret key = 2432 bytes (32 X25519 + 2400 ML-KEM-768), public key = 1216 bytes (32 X25519 + 1184 ML-KEM-768). These are not the same sizes as draft-09 (which uses ML-KEM-first ordering and different key representations).
- recv_seen entries: sorted strictly ascending, each u32 BE. No entry may equal
u32::MAX. Each entry must be< recv_count. No test vector exercises a non-emptyrecv_seenorprev_recv_seen— both F.21 vectors show the empty case (num_recv_seen = 0). To independently verify the encoding: arecv_seenset containing{0x00000001, 0x00000003}would serialize as00 00 00 02(count=2) followed by00 00 00 01 00 00 00 03(two u32 BE values in ascending order). A{0x00000003, 0x00000001}insertion order MUST produce the same ascending-sorted bytes — the sort is by value, not insertion order. - send_count = 1 in Alice initial:
init_alicesetssend_count = 1directly (§6.2) because counter 0 was consumed byencrypt_first_message. The first ratchetencrypt()call uses counter 1 and incrementssend_countto 2. A reimplementer who initializessend_count = 0and expects the firstencrypt()to set it to 1 produces a nonce collision with the first-message counter. - Absent recv_ratchet_pk with recv_count = 0: Valid for Alice's initial state (hasn't received anything). Guard 3 prevents
recv_count > 0with absentrecv_ratchet_pk.
Bob's initial state (after init_bob + one to_bytes() call, i.e., after receiving Alice's session init and calling decrypt_first_message):
Offset Size Field Value (this vector)
------ ------ ----------------------------- -------------------
0 1 version 0x01
1 8 epoch (u64 BE) 1
9 32 root_key [session-dependent]
41 32 send_epoch_key 0x00 * 32 (all-zero placeholder; replaced on first KEM ratchet step)
73 32 recv_epoch_key [session-dependent]
105 32 local_fp SHA3-256(Bob.IK_pub)
137 32 remote_fp SHA3-256(Alice.IK_pub)
--- Optional: send_ratchet_sk (absent — Bob hasn't sent yet) ---
169 1 present flag 0x00
--- Optional: send_ratchet_pk (absent) ---
170 1 present flag 0x00
--- Optional: recv_ratchet_pk (present = Alice's EK_pub from SessionInit) ---
171 1 present flag 0x01
172 2 length (u16 BE) 0x04C0 (1216)
174 1216 X-Wing public key [session-dependent = Alice's EK_pub]
--- Optional: prev_recv_epoch_key (absent) ---
1390 1 present flag 0x00
--- Optional: prev_recv_ratchet_pk (absent) ---
1391 1 present flag 0x00
--- Counters ---
1392 4 send_count (u32 BE) 0x00000000 (0)
1396 4 recv_count (u32 BE) 0x00000001 (1)
1400 4 prev_send_count (u32 BE) 0x00000000 (0)
--- Flags ---
1404 1 ratchet_pending 0x01 (Bob's first send triggers a KEM ratchet step)
--- recv_seen set ---
1405 4 num_recv_seen (u32 BE) 0x00000000 (0)
(empty — counter 0 was consumed by decrypt_first_message,
outside the ratchet; do NOT seed with {0})
--- prev_recv_seen set ---
1409 4 num_prev_recv_seen (u32 BE) 0x00000000 (0)
Total: 1413 bytes (vs Alice's 3847)
The size difference is entirely due to send_ratchet_sk (2432 bytes) and send_ratchet_pk (1216 bytes) being absent on Bob's side: Bob hasn't sent a ratchet message yet and therefore has no send-side ratchet key. Reimplementers whose first test is "Bob receives and serializes" will see a dramatically smaller blob than Alice's layout — this is correct, not a bug. Note also: send_epoch_key is all-zero in Bob's initial state (the epoch key for Bob's sending direction is a placeholder, set to a real value by the first KEM ratchet step when Bob sends his first message), while recv_epoch_key is the actual key Bob received during decrypt_first_message.
A reimplementer should round-trip their own serialization/deserialization against these offsets before attempting a cross-implementation session.
Test path for from_bytes → ChainExhausted: To test the ChainExhausted error path from deserialization (guard 24, §6.8), take any valid ratchet blob and replace the 8 bytes at the epoch field offset with 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF (u64::MAX in big-endian). Pass the modified blob to soliton_ratchet_from_bytes (or from_bytes_with_min_epoch with any min_epoch). The function MUST return ChainExhausted (-15), NOT InvalidData (-17). A deserializer that returns InvalidData for this input misclassifies a serialization-exhausted-but-recoverable state as corrupted data, causing the caller to permanently discard a session that could have been handled differently (§12 mode (3)). The epoch field is at bytes 1-8 of the blob (immediately after the 1-byte version tag — there is no reserved field; version occupies offset 0, epoch occupies offsets 1-8, see F.21 layout). Verify the exact offset from the F.21 layout map before patching.
F.22 Streaming AEAD with Compression (§15)
Note: A byte-exact compressed streaming vector is not provided because zstd output is not guaranteed to be identical across implementations, compression levels, or library versions for the same input. The frame format (RFC 8878) is standardized, but the encoder's block-splitting, match-finding, and entropy coding decisions vary.
To validate the compression integration path:
- Encrypt a known plaintext chunk with
compress=trueusing your zstd encoder. - Verify the stream header has
flags=0x01(bit 0 set). - Decrypt using your zstd decoder — the recovered plaintext must match the original.
- Cross-validate the AAD: identical to the uncompressed case (F.10) except
flags=0x01. The flags byte appears in both the stream header (byte 1 of the 26-byte header) and in the per-chunk AAD (byte 13, immediately after the 12-byte"lo-stream-v1"label and the 1-byte version field). Using the same base nonce as F.10 andflags=0x01, the compressed chunk_index=0, tag_byte=0x00 AAD is:
Byte 13 is the flags byte; bytes 14-37 are the base_nonce; bytes 38-45 are the chunk index; byte 46 is the tag_byte. A reimplementer who propagates6c6f2d73747265616d2d76310101101112131415161718191a1b1c1d1e1f2021222324252627000000000000000000 (47 bytes — identical to F.10 except byte 13 is 01 instead of 00)flags=0x01to the stream header but usesflags=0x00in the per-chunk AAD (or vice versa) will produce a ciphertext that their own implementation cannot decrypt — the AAD mismatch causes AEAD failure immediately, with no diagnostic pointing to the flag inconsistency. - Verify that a chunk compressed with
flags=0x01is rejected when decrypted withflags=0x00(wrong AAD), and vice versa.
Compressed + non-empty caller_aad: F.10 covers uncompressed + non-empty caller_aad; F.22 above covers compressed + empty caller_aad. The combination compressed + non-empty caller_aad is exercised by substituting flags=0x01 into the F.10 non-empty-caller_aad entry. Using the same inputs as F.10 (chunk_index=2, tag_byte=0x01, caller_aad="file-abc-123") but with flags=0x01:
aad: 6c6f2d73747265616d2d76310101101112131415161718191a1b1c1d1e1f202122232425262700000000000000020166696c652d6162632d313233
(59 bytes — identical to F.10 chunk_index=2 entry except byte 13 is 01 instead of 00)
A reimplementer who correctly handles each dimension separately but adds a spurious length prefix to caller_aad only when it is non-empty (a plausible mistake given that all other AAD fields are fixed-size) would pass all F.10 and F.22 vectors and only fail here, manifesting as an AEAD mismatch in cross-implementation testing.
Empty final chunk with compress=true — AAD still uses flags=0x01: A stream initialized with compress=true that ends with an empty final chunk (is_last=true, 0-byte plaintext) bypasses compression per §15.5, but the per-chunk AAD still uses flags=0x01 — the stream's compression configuration, not the per-chunk outcome. Using the same base nonce as F.10 and flags=0x01, the empty final chunk (chunk_index=0, tag_byte=0x01) AAD is:
6c6f2d73747265616d2d76310101101112131415161718191a1b1c1d1e1f2021222324252627000000000000000001
(47 bytes — identical to F.10 step 4 except byte 13 is 01 (flags=compressed) and byte 46 is 01 (tag_byte=final))
A reimplementer who writes flags=0x00 for this chunk ("compression was bypassed, so this chunk's flag should be 0") produces an AAD mismatch and immediate AEAD failure on decrypt, with no diagnostic pointing to the flag inconsistency.
The critical interop property is not the compressed byte sequence but the encrypt-then-decrypt round-trip and the AAD binding of the flags byte.
F.23 Storage Blob Wire Format (§11.1)
The storage blob has a fixed-layout header followed by an AEAD-protected body. No fabricated ciphertext bytes are included — the test for this section is structural validation of the header and the AAD construction, not a known-answer ciphertext output.
Wire layout:
Offset Size Field
------ ---- -----
0 1 version (u8) — storage key version, 1-255; 0 is reserved (AeadFailed via keyring miss — NOT InvalidData; §11.1 prohibits a pre-AEAD version-0 check as an oracle)
1 1 flags (u8) — bit 0 = FLAG_COMPRESSED (0x01); bits 1-7 reserved (AeadFailed if set)
2 24 nonce — 192-bit random; unique per encrypted blob
26 ≥16 ciphertext + Poly1305 tag — XChaCha20-Poly1305 AEAD output
Minimum blob: 42 bytes (1 + 1 + 24 + 16). A valid blob with zero plaintext bytes still carries a full 16-byte Poly1305 tag.
AAD binding (§11.4): Both version and flags are included in the AAD passed to AEAD. The AEAD operation covers bytes [26..] with AAD constructed from version, flags, channel_id, and segment_id. Neither version nor flags are inside the ciphertext — an implementation that omits them from the AAD produces a malleable blob (see §11.1).
Decryption read path:
- Assert
len(blob) >= 42; elseAeadFailed(per §12 oracle-collapse table — a sub-42-byte blob returnsAeadFailed, notInvalidDataorInvalidLength, to prevent an oracle distinguishing "too short to contain valid ciphertext" from "plausible-length blob with wrong key/tag"). - Read
version = blob[0],flags = blob[1],nonce = blob[2..26]. - Reject unknown flag bits as
AeadFailed. (version == 0is not a separate early reject: version 0 is never loaded into the keyring, so step 4's key-not-found lookup returnsAeadFailed— the same outcome as any other unrecognized version.) - Look up encryption key by
version; if absent →AeadFailed(notUnsupportedVersion— §11.3 oracle). - Reconstruct AAD from
version,flags,channel_id,segment_id(§11.4.1 or §11.4.2). - Decrypt
blob[26..]with XChaCha20-Poly1305 usingnonceand AAD. - If compressed (
flags & 0x01), decompress with zstd.
F.8 covers the AAD construction with a full worked example. Combine F.8 with the wire layout above to validate a complete storage encrypt/decrypt round-trip.
F.24 ML-DSA-65 Seed-to-Public-Key (§2.1 Cross-Library Verification)
This vector verifies the §2.1 portable cross-library verification procedure: extract candidate 32 bytes, call ML-DSA.KeyGen_internal(candidate), compare the resulting public key against the known public key.
Input: seed ξ = [0xAA] × 32 (32 bytes, all 0xAA)
seed (32 bytes, hex):
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Expected output: ML-DSA-65 public key (1952 bytes). Computed via MlDsa65::from_seed(ξ).verifying_key().encode() using the ml-dsa Rust crate (version used by soliton — see Cargo.lock). ML-DSA.KeyGen_internal(ξ) runs FIPS 204 Algorithm 1 deterministically with no CSPRNG input.
public_key (1952 bytes, hex):
2a3cd553791045a9363393c3f720866028e048bf598a099e8f81043491fb7095
71fe64caa83e93bac8c1c931d1a148aa8d04a37d42c6cfdaebc0638c7fff12b7
0ab0d76c209239bc4bbdfb36d2a676098792a1f9a5fd388292300e416e693633
1026274889fc21b80701d39a2c564f11417a20c2be4dae84ab0743071bb97bf8
c65c4520a6fd5c4e48e759bfac3e857a5fc23de915dee91d3fe6e83b5230ec28
d77b478c4831d19bf26e697abf5c890f527e6fef6f0499b69a490af5dc6e5e43
3b1c168ba9e9e51aab125d0927d1aaa5cc17cd649b6a5ca83418b163d9dd487c
fcbdcebb7d6386ed26ff22f4cdd329dfce0de2667d1809401a649cdcf4c4232b
06abcaa82c2d8277a12622045de61d224ac6913b488d885822b2c1a7e5c1be41
61eed1c5a79da7d86738e4d77740591090216554246cc12aa89ebd9c024e054a
1a9fc28d18b6263dd95cd9e5e50a28a615742f1c43a1326bb004f9fe0856672a
7e7873226d222f949032c17f3a13e7a9a1812f496cfa88d1261bde89a7d8117f
cd1e7fa50cc26072d516613cb75f457b7f7681f9b5c58c0fa13be6fc56ec446f
5c1347b62cde77c950d368fc63329a35f6584bfd74ae769fafdb7982601be1ad
d10816d57b85a647b7bf772a21d56453303b67825c58a9f71b0fb644b6ce6351
2dcd054ae0dc5995abed531098a1235c757ceada7e643004530173eccb2e2d3f
114a578cd7cca8304a4cab4fe39e206a089193f2566ff811da3eca6e634594e9
b330b85f10fb50304b997d387189aa121746aba38897c1691ca2fe590e2f12e1
bfb84106b043dcb8e8ec7009d8247cab028b90e792b9d186f20ed3e6ec0dd419
b54f953572ade2e144c3bade312cbe92d52e7c8ed350af61c24848dfa4686f30
fecb15b25e5618797e78add739e542b725f517fdd0ab4084a5d4da81bfe6e226
72b5f8817be017a28674e97d0f7f8410e7bb7257ab5131e1b56ca21cc7c57b75
c4a5b05992971f46d2648675b829ed71bfb49b5dfba39c071ac95cbe42d9dfc1
1bb81ad316e7656b55dba3f8a5786c050607d355791d5406c9e21c99a6ba2763
44eb1e755c8d83344a344ad5ea149051b91729c7cafdf5252d5a766ede05ad9e
1ef06e5dbf7de24486155caa2e92275d54f8c2df4e85f29605b975a9c2bbe775
f33761fc05d894a0834f96f5355cb63b83f0e90e5b5111bdca71611c93df96db
72a1db4723fdf4184c7f62f1e3efca954a772667effc9b553b7ab91c644cdbfb
a15c5f5c9e6b38e4df2ade1dd0b098739c47b39f5520eb2f584d0e353f90eddd
20320800545eec44c51f6c41618dc1451041ecb958351e2ef04a5fc13a7195c9
bc0a397944da82bdfa7ac46aeb05bee813944b25e66b311263f9d0f3d9bb6f5e
242a53b2ab9322eacf70388bd5be0ad4990ae9d7e3abaca428ce50c6eb35c9ee
0ced604ac17db0443b2ad1fe6d9bd9397457f2ce0f5e8665d9acd96b924344ad
3bc45cc0cf392d48b2e4dabbb07da0e2ba5561d346a952bd20054d035a4ff378
a4108dc0092ee25b40be9056a235aaf9aa314874351c99ec0bcbfa7e9bb6b1bd
e74fa506f863008058482be9fcb67449b2c2566b6d011985c4311fc5f1551bfc
a25699123a68e2a790cf2388120f28ff411836a3e95a97c6f60633b7cc27bea6
5f9323abb9731b222e28db4765748a3bfcdb962f0e290025e3b28b5642d891cd
dc09f1489aea45eee7f57ff231be716204192fbcf5fb574bae1d7e1e6e039bfe
f7dcd93162c093c11a80c2ab5b127a4f214ebb03dae20dcc38afa246320ee8e4
4a2ca97fd265fc7813ddd5efbacb981f401fbaa895e60a58a6e7d44bc1a17873
d523bc2659256e0e73eec555a6f7c799c74902e0ddb593c8d76937623feb0bcc
ee816d42841cf7935383435ba4fc4d4b6d36235da237fc2dcdd6d0e774157616
503a174417cd325e2c3ebe0b520d94f6f4c18a8d01a2daa087ce0fce85e61aa4
f126664d073773f8a927ed8c4d4d9724449af637a8e3a8bc15ddf2ec19f9f5b2
d0ad84dbf2a59fce072b132ba38bf5d985a966cabce4cc5f31a87101b56eb7e6
2b37e3fd0091afb9c8063cba5184234ceb4938313440678d4dd6c9eafa88d986
6e56fe75fa063396a66be4833ebef1e0b25ced5d5f5bceb26b7cb4775e792926
58c81b2405d2b488a0a881a2589749acbd0af912308aff5450d87dde8a0ee25b
7fee2a7d3b76e5237bfe6fc890f009438e539e1719864958c2bf3b63e43fae41
e591b53a8fbac2ec3f37d5b74a6d2e83b9a1e050ffc082e415e39288d51fabbb
1c791c2ccccef44e6f9c2886c506e561ad372cc20b691aba206d14d007518c3f
4b7aeabfe836f5c55fd65fb85d09948b652e1156983678ab6dffe739ca888614
3fe630b2a10741aa81d6a79fdfd3d144f2ce43d39ad5cd55e42d4f6deef1f406
2fd03aa83676a0a945dbd702e5f8c111c84d74c3d5a53d72a426c8ca5bc3f4f2
a4226d2efe1c1a476fd65d69a2d85216213108b45e5567bcba7f9ac9c73d7173
21561d56589eafa13f49fbeaa1ad47a3f2cb4f4f64f8b2055ea5968035a12b34
f0735981e2f3ebf50c51ba7e1f3e1f9b0ab892eb90e04d3a4d5e924b4280fb5f
e9e018f9d0bec7b53a097986abc61f6b3c9ef97dc30e97b4841cd1d64303646b
15b4fc5f99e97af00bc205a5c53097f572f0914fbc706c7164e1564396bfaa7d
ac3531d2c109a62c16ef9e81b49dbd91d7669bf5cf2ff875539b2ee691215114
How to use: A reimplementer who cannot call to_seed() on their ML-DSA library (e.g., because the library does not expose the 32-byte seed) uses the procedure in §2.1: extract the candidate 32 bytes from whatever API the library provides, call ML-DSA.KeyGen_internal(candidate) (FIPS 204 §6.1, deterministic — no CSPRNG input), and compare the resulting 1952-byte public key against the known public key. If the result matches this vector for ξ = [0xAA] × 32, the candidate extraction is correct. Note: this vector uses sign_internal / verify_internal (not the standard FIPS 204 domain-separated API — see §2.1 module doc); the public key encoding itself is standard FIPS 204 pkEncode format and is byte-for-byte comparable with any compliant ML-DSA-65 implementation.
F.25 Standalone HKDF-SHA3-256 Primitive (§5.4, §6.4)
Purpose: Validates HKDF-SHA3-256 in isolation before any composed operation. The primary interop trap: RFC 5869 test vectors use HMAC-SHA-256 (64-byte HMAC block size), not HMAC-SHA3-256 (136-byte block size — §4.3). A reimplementer who pads HMAC inputs to 64 bytes instead of 136 produces wrong output in every derived key with no diagnostic.
salt (32 bytes): 0000000000000000000000000000000000000000000000000000000000000000
ikm (64 bytes): 0101010101010101010101010101010101010101010101010101010101010101
0101010101010101010101010101010101010101010101010101010101010101
info (15 bytes): "lo-test-hkdf-v1" (raw UTF-8, no length prefix)
len: 64
output (64 bytes):
4a694c255636bd5a472c807cf1400a05f78a4a3e93b7f663dd6825c9d496904c
6224e025169b8c67e62ed3b10129da39c546d6e84c84920f69232fd8e76e7cf0
Verified by tests/compute_vectors.rs::f25_hkdf_sha3_256 in the reference implementation.
F.26 Standalone XChaCha20-Poly1305 Primitive (§3, §6.5)
Purpose: Validates the AEAD primitive directly: (key, nonce, plaintext, aad) → ciphertext || tag. Isolates AEAD bugs — wrong key/nonce byte ordering, wrong AD binding, wrong tag placement — before full session integration.
key (32 bytes): 0202020202020202020202020202020202020202020202020202020202020202
nonce (24 bytes): 030303030303030303030303030303030303030303030303
plaintext (11 bytes): "hello world" (raw UTF-8)
aad (15 bytes): "lo-test-aead-v1" (raw UTF-8)
ciphertext (11 bytes): 356c4d3352734de8f25fe3
tag (16 bytes): 91c8f97e537cf5c7d3f07d2b03388f77
wire output (27 bytes, ciphertext || tag):
356c4d3352734de8f25fe391c8f97e537cf5c7d3f07d2b03388f77
Verified by tests/compute_vectors.rs::f26_xchacha20_poly1305 in the reference implementation.
soliton_aead_decrypt error boundary — InvalidLength vs AeadFailed for undersized ciphertext: The standalone AEAD function has an asymmetry compared to ratchet/stream decrypt; this is not shown in a success vector but is documented here for completeness.
ciphertext_len |
soliton_aead_decrypt return |
|---|---|
| 0 | InvalidLength (-1) — the CAPI zero-length guard fires before any AEAD operation |
| 1-15 | AeadFailed (-4) — non-zero length passes the CAPI guard; too short to contain a 16-byte Poly1305 tag, so AEAD authentication fails |
| ≥ 16, wrong key/tag | AeadFailed (-4) — AEAD authentication failure |
| ≥ 16, correct | 0 (success) |
Contrast with ratchet/stream: soliton_ratchet_decrypt and soliton_stream_decrypt_chunk return AeadFailed for ALL undersized inputs including len = 0 (oracle-collapse requirement, §12). soliton_aead_decrypt with len = 0 returns InvalidLength — the CAPI zero-length guard fires first. A binding author who tests only the success path and then applies the ratchet/stream AeadFailed pattern to soliton_aead_decrypt diverges from the reference for the len = 0 case.
F.27 HybridSign / HybridVerify (§3.1)
Purpose: Validates the composite signature layout (Ed25519 || ML-DSA-65 concatenation) and the Sign_internal / Verify_internal internal-API requirement (not the FIPS 204 public API). Non-determinism is eliminated by pinning ML-DSA rnd = [0x00] × 32 — this is test-only; production signing uses fresh getrandom entropy.
Identity secret key sub-components (used to construct 2496-byte SK):
X-Wing sk (bytes 0..2432): [0x01] × 2432 (not used for signing)
Ed25519 seed (bytes 2432..2464): [0x02] × 32
ML-DSA-65 seed (bytes 2464..2496): [0x03] × 32
message (15 bytes): "lo-test-sign-v1" (raw UTF-8)
ML-DSA rnd (32 bytes, test-only): [0x00] × 32
Ed25519 signature (bytes 0..64):
21aafa2d66a4774e163064717412a2694527c84cdc57e93370ba05738940bdd0
facc5cb6330088ce849635ac41a0099842a40ef82cb0046f6978eeb7196be00f
ML-DSA-65 signature (bytes 64..3373): [3309 bytes — see reference test for full hex]
composite signature (3373 bytes total):
Ed25519 component: 21aafa2d66a4774e163064717412a2694527c84cdc57e93370ba05738940bdd0
facc5cb6330088ce849635ac41a0099842a40ef82cb0046f6978eeb7196be00f
ML-DSA-65 component starts: 1b47e0e18a96f465b42396b24a77f72f...
ML-DSA-65 component ends: ...000000000000000000000000000000000000000000000000000a1316161b1e
Full 6746-char hex is in EXPECTED_F27 in tests/compute_vectors.rs. Verified by tests/compute_vectors.rs::f27_hybrid_sign_verify which also runs hybrid_verify against the assembled composite. In-document verification limitation: The 3309-byte ML-DSA-65 component is too large to embed in full; no SHA3-256 hash of the ML-DSA-65 component is provided inline. Standalone verifiers who cannot access the repository must compute SHA3-256(composite[64..3373]) from their own implementation output and cross-check against a trusted build, rather than comparing against an in-document hash.
Missing partial-failure vectors for HybridVerify: No vectors are provided for the two partial-failure cases: (1) Ed25519 component valid + ML-DSA-65 component corrupted (e.g., byte 64 flipped); (2) ML-DSA-65 component valid + Ed25519 component corrupted (e.g., byte 0 flipped). §3.2 requires that HybridVerify evaluates BOTH components in constant time before combining the results — a && short-circuit that returns early on the first failure leaks which component failed. Partial-failure vectors would verify this by checking that the function returns VerificationFailed for both cases. Reimplementors MUST verify their HybridVerify does not short-circuit: evaluate Ed25519.Verify() AND MLDSA.Verify() independently, then combine with & (not &&).
F.28 Streaming AEAD End-to-End Wire Vector (§15)
Purpose: Provides complete wire bytes for a two-chunk stream. The primary interop trap: tag_byte is outside the AEAD call (used in AAD and XORed into the nonce), while the 16-byte Poly1305 tag is inside. A reimplementer who passes tag_byte as plaintext produces a different wire format — passes all nonce/AAD checks but fails AEAD on the receiver.
Non-final chunk size note: This vector uses 16-byte plaintext for the non-final chunk (chunk 0) for compactness. In production, soliton_stream_encrypt_chunk with is_last=false requires the plaintext to be exactly CHUNK_SIZE (1,048,576 bytes) — a non-final chunk whose plaintext is not exactly CHUNK_SIZE is rejected with InvalidData (-17) by the core library (not InvalidLength — InvalidLength fires only when the output buffer is too small; the constraint on plaintext size is a semantic content check, which maps to InvalidData). The F.28 vector was computed at the primitive level (direct AEAD calls with the correct nonce and AAD, bypassing the CAPI chunk-size guard), so the wire bytes are protocol-correct. A reimplementer building streaming AEAD MUST enforce the same constraint: non-final chunks must be full size (1 MiB), and only the final chunk may be smaller. The AEAD itself does not enforce this — it will accept any size — so the guard must be in the framing layer.
key (32 bytes): 0404040404040404040404040404040404040404040404040404040404040404
base_nonce (24 bytes): 050505050505050505050505050505050505050505050505
flags: 0x00 (no compression)
caller_aad: empty
header (26 bytes):
01 00 050505050505050505050505050505050505050505050505
hex: 0100050505050505050505050505050505050505050505050505
chunk 0 — non-final (tag_byte=0x00), plaintext=[0x41]×16:
nonce mask = chunk_index(8B) || tag_byte(1B) || 0x00×15(15B) = 24 bytes total
= 0000000000000000 || 00 || 000000000000000000000000000000
nonce = base_nonce XOR (all-zero mask) = base_nonce (unchanged)
aad = "lo-stream-v1" || 0x01 || 0x00 || base_nonce || 0000000000000000 || 0x00
wire (33 bytes): 00d5425e7085cc776bc8c608ad84c41cc37eefb10d2b859ebddf8c1187c616c0c4
tag_byte: 00
ciphertext: d5425e7085cc776bc8c608ad84c41cc3
tag: 7eefb10d2b859ebddf8c1187c616c0c4
chunk 1 — final (tag_byte=0x01), plaintext=[0x42]×8:
nonce mask = chunk_index(8B) || tag_byte(1B) || 0x00×15(15B) = 24 bytes total
= 0000000000000001 || 01 || 000000000000000000000000000000
nonce = base_nonce XOR mask
= 0505050505050504 || 04 || 050505050505050505050505050505
(8 B) (1 B) (15 B — 30 hex chars)
flat (48 hex chars): 050505050505050404050505050505050505050505050505
aad = "lo-stream-v1" || 0x01 || 0x00 || base_nonce || 0000000000000001 || 0x01
wire (25 bytes): 01aac61cb7b722895cb246433e7ebc081e92150081150d345d
tag_byte: 01
ciphertext: aac61cb7b722895c (8 bytes — matches 8-byte plaintext)
tag: b246433e7ebc081e92150081150d345d (16 bytes — Poly1305 tag)
full stream hex (84 bytes):
010005050505050505050505050505050505050505050505050500d5425e7085cc776bc8c608ad84c41cc37eefb10d2b859ebddf8c1187c616c0c401aac61cb7b722895cb246433e7ebc081e92150081150d345d
Verified by tests/compute_vectors.rs::f28_streaming_aead_wire in the reference implementation.
F.29 Argon2id + XChaCha20-Poly1305 Passphrase-Protected Key Blob (§10.6)
Purpose: End-to-end vector for the §10.6 recommended composition: salt(16) || nonce(24) || AEAD_ciphertext. F.20 covers Argon2id output in isolation; this covers the full assembly where the derived key feeds directly into XChaCha20-Poly1305 with the identity fingerprint as AAD. The easy mistake: using the wrong AAD (empty, or the salt, or something other than the identity fingerprint) or inserting an extra HKDF step between Argon2id output and the AEAD key — both produce incompatible ciphertext with no error at encryption time.
password (18 bytes): "lo-test-passphrase" (raw UTF-8)
argon2_salt (16 bytes): 06060606060606060606060606060606
argon2 params: OWASP_MIN (m=19456 KiB, t=2, p=1)
aad / fingerprint (32 bytes): SHA3-256([0x00] × 3200)
= 1fc29a619ef720eaf2966023f1d22c797a31a7ad6c9fd94b7fb28dfff94c5e4b
derived_key (32 bytes, Argon2id output):
2058fdb73306ec7271061be269fccaf39756b8666248172d6923976e377f5d30
aead_nonce (24 bytes): 070707070707070707070707070707070707070707070707
plaintext (17 bytes): "test-key-material" (raw UTF-8)
ciphertext || tag (33 bytes):
f90394fa7144500a63da86ca3ff6d900f855314f4c9030ab88b060a0ab41b9eede
blob (73 bytes, salt || nonce || ciphertext || tag):
06060606060606060606060606060606
070707070707070707070707070707070707070707070707
f90394fa7144500a63da86ca3ff6d900f855314f4c9030ab88b060a0ab41b9eede
hex: 06060606060606060606060606060606070707070707070707070707070707070707070707070707f90394fa7144500a63da86ca3ff6d900f855314f4c9030ab88b060a0ab41b9eede
Verified by tests/compute_vectors.rs::f29_passphrase_key_blob in the reference implementation.
F.30 from_bytes_with_min_epoch Rejection Boundary (§6.8)
Purpose: Verify the strict > boundary in anti-rollback deserialization. The condition is epoch > min_epoch — equal epoch is rejected. This is the boundary that prevents replaying the current epoch's blob (not only older ones).
Test procedure: Obtain any valid ratchet blob. Read the epoch value from bytes 1-8 (u64 big-endian, immediately after the 1-byte version tag — see F.21 layout). Let N be the deserialized epoch.
blob epoch N
from_bytes_with_min_epoch(blob, N - 1) → Ok ← epoch N > min_epoch N-1 ✓
from_bytes_with_min_epoch(blob, N) → InvalidData (-17) ← epoch N ≤ min_epoch N (equal, not strictly greater)
from_bytes_with_min_epoch(blob, N + 1) → InvalidData (-17) ← epoch N < min_epoch N+1
Patching for boundary testing: To produce a blob with a specific epoch without running a full session, take any valid blob and overwrite bytes 1-8 with the desired epoch as u64 big-endian. Epoch = 1 → 00 00 00 00 00 00 00 01. The blob must otherwise be valid (pass from_bytes guards) — patch only the epoch field.
Off-by-one hazard: A reimplementer who uses >= instead of > accepts the current epoch's blob, defeating rollback protection for the common case where the adversary replays the most recent serialized state.
Error code: InvalidData (-17) on rejection. Not InvalidLength, UnsupportedVersion, or any other variant — those would let the caller misclassify a rollback attempt as a format error.
F.31 Stream Header — Compressed Stream (§15.2)
Purpose: Pin the 26-byte stream header wire encoding for flags=0x01 (compressed). The flags byte distinguishes compressed from uncompressed streams and is also bound into every per-chunk AAD (F.10, F.22) — a reimplementer who misplaces or omits it in either the header or the AAD produces unreadable ciphertext.
Using the F.10 base_nonce (101112131415161718191a1b1c1d1e1f2021222324252627):
version (1 byte): 01
flags (1 byte): 01 (bit 0 = compressed)
base_nonce (24 bytes): 101112131415161718191a1b1c1d1e1f2021222324252627
header (26 bytes): 0101101112131415161718191a1b1c1d1e1f2021222324252627
The header is a concatenation with no length prefix, no delimiter. Any reimplementer who inserts a 1-byte or 2-byte length prefix before version, or who encodes flags as a 2-byte field, shifts all subsequent byte offsets and causes a parse failure at the first chunk.
F.32 Streaming AEAD Random-Access Byte Offset (§15.3)
Purpose: Confirm the byte_offset(N) formula for random-access decryption. A reimplementer computing the wrong chunk stride cannot seek correctly and will either read garbage or fail AEAD authentication on every chunk beyond the first.
Parameters: chunk_size = 1 MiB = 1,048,576 bytes.
chunk wire size = 1 (tag_byte) + 1,048,576 (ciphertext) + 16 (AEAD tag)
= 1,048,593 bytes
byte_offset(N) = 26 + N × 1,048,593
byte_offset(0) = 26 ← first chunk starts immediately after 26-byte header
byte_offset(1) = 1,048,619 ← 26 + 1,048,593
byte_offset(2) = 2,097,212 ← 26 + 2 × 1,048,593
byte_offset(N) = 26 + N × 1,048,593
The 26 addend is the fixed stream header size (version=1, flags=1, base_nonce=24). A reimplementer who omits the header from the offset (using N × 1,048,593 directly) will seek 26 bytes too early on every chunk except chunk 0, producing AEAD failure.
The final chunk may be shorter than 1 MiB; byte_offset(N) gives the start of chunk N regardless of preceding chunk lengths only when all preceding chunks are full-size (1 MiB). Random access to a non-final chunk in a variable-chunk-size stream requires an index. The stride formula applies exclusively to fixed-1-MiB-chunk streams.
F.33 HMAC-SHA3-256 with Long Key (§3.2)
Purpose: Discriminates SHA-2 and SHA3-256 HMAC implementations at the block-size boundary. SHA3-256's HMAC block size is 136 bytes; SHA-2-256's is 64 bytes. A 100-byte key falls above the SHA-2 threshold (forcing key hashing to 32 bytes in a SHA-2 implementation) but below the SHA3-256 threshold (padding the key to 136 bytes without hashing). All existing HMAC vectors use 32-byte keys, which lie below both thresholds and cannot expose this mismatch.
key (100 bytes): AB × 100
data (10 bytes): "lo-hmac-v1" (ASCII)
MAC (32 bytes): aa5575019f7aade135d379d92699d13d62cded9208869f9c9898d687d93ae293
A SHA-2 implementation would hash the 100-byte key to 32 bytes before XOR-padding. A SHA3-256 implementation pads 100 bytes to 136 bytes by appending zeros — no preliminary hashing. Both produce distinct MAC values for this key length; a reimplementer who produces the same MAC as above for all existing 32-byte-key vectors but fails here has used the wrong hash function in their HMAC.
Verified by tests/compute_vectors.rs::f33_hmac_sha3_256_long_key.
F.34 SHA3-256 of First-Message AAD with OPK (§5.4 Step 7)
Purpose: Hashing the full AAD to a 32-byte value provides a fixed-size discriminator for the 4,741-byte with-OPK AAD structure. A reimplementer who omits ct_opk from the encoding, reverses the ct_opk/opk_id order, or omits the "lo-dm-v1" label prefix produces a different hash.
Inputs (synthetic, fixed):
sender_fingerprint (32 bytes): AA × 32
recipient_fingerprint (32 bytes): BB × 32
crypto_version: "lo-crypto-v1"
sender_ek (1216 bytes): CC × 1216
ct_ik (1120 bytes): DD × 1120
ct_spk (1120 bytes): EE × 1120
spk_id: 42 (0x0000002A)
ct_opk (1120 bytes): FF × 1120
opk_id: 7 (0x00000007)
AAD wire layout: "lo-dm-v1"(8) || sender_fp(32) || recipient_fp(32) || encode_session_init(4669) = 4741 bytes total.
SHA3-256(first_message_aad with OPK):
ba8e4c4ffb1330f47e5ca95a63671970036a1f3d07934836548efa0403e84815
Verified by tests/compute_vectors.rs::f34_first_message_aad_with_opk.
F.35 HybridSign over SPK Message (§5.3)
Purpose: Domain label vector for SPK signing. The signed message is "lo-spk-sig-v1" || SPK_pub. A reimplementer who uses the wrong label (e.g., "lo-kex-init-sig-v1") or signs only SPK_pub without the label produces a composite signature that hybrid_verify rejects.
Inputs (same synthetic identity key as F.27):
Ed25519 seed: 02 × 32
ML-DSA-65 seed: 03 × 32
ML-DSA-65 rnd: 00 × 32 (test only — production uses getrandom)
message: "lo-spk-sig-v1" (13 bytes) || CC × 1216 (total 1229 bytes)
Output: Ed25519 sig (64 bytes) || ML-DSA-65 sig (3309 bytes) = 3373 bytes total.
composite[0..64] (Ed25519): 2856bb008aa260e6b541ead779730ad350d97feb39db4829cb4ef5520979f3c3820bda50d51fec0e16ae1b7bb2cba8016ab389222c51b46af1fa223914ad8a01
composite[64..3373] (ML-DSA-65): 93759b7f59dd... (see EXPECTED_F35 in compute_vectors.rs)
Full 6746-character hex in tests/compute_vectors.rs::EXPECTED_F35. Verified by tests/compute_vectors.rs::f35_hybrid_sign_spk. In-document verification limitation: The ML-DSA-65 component is truncated; no inline SHA3-256 hash is provided. Standalone verifiers must compare SHA3-256(composite[64..3373]) from their implementation against a trusted build.
F.36 HybridSign over encode_session_init (§5.4 Step 6)
Purpose: Domain label vector for session-init signing. The signed message is "lo-kex-init-sig-v1" || encode_session_init(si). A reimplementer who signs the raw SessionInit fields directly (instead of the encoded form) or who uses the SPK label produces a different composite signature.
Inputs (same synthetic identity key as F.27; synthetic SessionInit without OPK):
Ed25519 seed: 02 × 32
ML-DSA-65 seed: 03 × 32
ML-DSA-65 rnd: 00 × 32 (test only)
SessionInit: crypto_version="lo-crypto-v1", sender_fp=AA×32, recipient_fp=BB×32,
sender_ek=CC×1216, ct_ik=DD×1120, ct_spk=EE×1120, spk_id=42,
ct_opk=None, opk_id=None
encode_session_init output: 3543 bytes (no-OPK path)
message: "lo-kex-init-sig-v1" (18 bytes) || si_encoded (3543 bytes) = 3561 bytes
Output: Ed25519 sig (64 bytes) || ML-DSA-65 sig (3309 bytes) = 3373 bytes total.
composite[0..64] (Ed25519): c53f65e56414c595257a2e7233b91b5c52f2da83edc9c6245c63091dc83815c4c72fc53db16e5bd658826641c15e5dc33397e85b4447bff11213eb4273376c03
composite[64..3373] (ML-DSA-65): 397e85b4... (see EXPECTED_F36 in compute_vectors.rs)
Full 6746-character hex in tests/compute_vectors.rs::EXPECTED_F36. Verified by tests/compute_vectors.rs::f36_hybrid_sign_session_init. In-document verification limitation: The ML-DSA-65 component is truncated; no inline SHA3-256 hash is provided. Standalone verifiers must compare SHA3-256(composite[64..3373]) from their implementation against a trusted build.
F.37 LO-Auth HMAC Token Derivation (§4)
Purpose: The LO-Auth proof token is HMAC-SHA3-256(shared_secret, "lo-auth-v1"). This vector isolates the HMAC step from the KEM by using a synthetic shared secret. The KEM round-trip is covered by X-Wing unit tests; the label and key/data order are the reimplementation risks addressed here.
shared_secret (32 bytes): 08 × 32
label (10 bytes): "lo-auth-v1"
token = HMAC-SHA3-256(key=shared_secret, data=label):
4e14e7ab92b70dd587a558e208cbcd98fd933048a2b2bf90e188e1d9b04f6e2a
A reimplementer who swaps key and data (HMAC(key=label, data=ss)) or who uses a different label (e.g., "lo-auth" without the version suffix) produces a different 32-byte value. The token must be compared constant-time using hmac_sha3_256_verify_raw — never with ==.
Verified by tests/compute_vectors.rs::f37_lo_auth_hmac.
F.38 Streaming AEAD UnsupportedVersion Rejection (§15.8)
Purpose: Verify that stream_decrypt_init (and soliton_stream_decrypt_init at the CAPI) returns UnsupportedVersion when the stream header's version byte is not 0x01. This is a rejection boundary test — no shared secret is produced.
The 26-byte stream header format is version(1B) || flags(1B) || base_nonce(24B) (§15.2 / F.31). Version 0x01 is the only currently defined version; any other byte triggers UnsupportedVersion.
Test inputs (any valid key and a header with a non-0x01 version byte):
key (32 bytes): 0404040404040404040404040404040404040404040404040404040404040404
header — version=0x00 (26 bytes): 0000050505050505050505050505050505050505050505050505
version byte: 00 (not 0x01)
flags: 00
base_nonce: 050505050505050505050505050505050505050505050505
header — version=0x02 (26 bytes): 0200050505050505050505050505050505050505050505050505
version byte: 02 (not 0x01)
flags: 00
base_nonce: 050505050505050505050505050505050505050505050505
Expected result for both inputs: stream_decrypt_init returns UnsupportedVersion (-10). No decryptor object is created.
Additional rejection inputs: any header with version byte in [0x00, 0x02..0xFF] must produce UnsupportedVersion. Version 0x01 with any valid flags byte and nonce produces Ok.
Reimplementation check: A reimplementer who validates only that the version byte is non-zero (instead of exactly 0x01) will accept version 0x02 silently. A reimplementer who skips version validation entirely will attempt to parse future-format streams with current-version rules, producing wrong decryption output with no error.
Verified by the decrypt_init_wrong_version (version=0x00) and decrypt_init_version_0x02 (version=0x02) tests in soliton/soliton/src/streaming.rs #[cfg(test)].
F.39 Missing Vectors — Acknowledged Gaps
The following vectors are not provided in-document. Each represents an integration failure mode that existing vectors do not cover. Reimplementors SHOULD add these as integration tests against the reference implementation.
F.39.1 First-Message Encrypt/Decrypt End-to-End KAT (§5.4 Step 7, §5.5 Step 5)
No vector combines F.11's epoch_key + a 24-byte random nonce + F.18's first-message AAD + a known plaintext into a complete encrypted first message with expected ciphertext. The primary integration failure mode — Alice and Bob deriving different AAD values — produces AeadFailed on Bob's side with no diagnostic pointing to the AAD divergence. To add this vector: run encrypt_first_message(epoch_key, plaintext, aad) with a pinned nonce and record the 24-byte nonce + ciphertext output; the corresponding decrypt_first_message call with the same inputs must reproduce the plaintext.
F.39.2 encode_prekey_bundle (§5.3)
No encode_prekey_bundle KAT is provided (with or without OPK). F.13 covers encode_session_init; the bundle format is structurally different (no sender fingerprints, no KEM ciphertexts). Field ordering and the absence of length prefixes on IK_pub, SPK_pub, and SPK_sig are the primary reimplementation hazards. To add these vectors: call encode_prekey_bundle with known key material and record the SHA3-256 of the encoded output for both the OPK-present and OPK-absent cases.