# soliton Cheat Sheet

Quick reference for the soliton v1 API. For the full cryptographic specification see [`Specification.md`](../Specification.md). For the formal security model see [`soliton/Abstract.md`](Abstract.md).

**Contents:** [Overview](#overview) · [Errors](#error-handling) · [Identity](#identity) · [Auth](#auth) · [Verification](#verification) · [KEX](#kex--lo-kex-asynchronous-key-exchange) · [Ratchet](#ratchet--lo-double-ratchet) · [Call Keys](#call-keys) · [Storage](#storage--encrypted-keyring-storage) · [X-Wing](#x-wing--post-quantum-kem) · [Primitives](#primitives--low-level-cryptographic-operations) · [Argon2id](#argon2id--password-based-key-derivation) · [Streaming AEAD](#streaming-aead--chunked-encryption-for-large-payloads)

---

## Overview

**soliton** is a pure-Rust post-quantum cryptographic library implementing the Soliton specification. It provides composite identity keys combining classical and post-quantum algorithms, hybrid signatures, KEM-based authentication, asynchronous key exchange, and message encryption.

| Primitive | Algorithm | Key/Output Size | Notes |
|-----------|-----------|-----------------|-------|
| Identity KEM | X-Wing (X25519 + ML-KEM-768) | 1216 B pk / 2432 B sk *(X-Wing component; full IdentityKey: 3200/2496 B)* | Hybrid KEM for identity operations |
| Identity Signing | Ed25519 + ML-DSA-65 | 3373 B signature | Both components must verify |
| Symmetric AEAD | XChaCha20-Poly1305 | 256-bit key / 128-bit tag | Constant-time (ARX only) |
| Hash | SHA3-256 | 32 B output | FIPS 202; used for fingerprints, HMAC, HKDF, KEM combiner |
| KDF | HKDF-SHA3-256 | Variable output (max 8160 B) | RFC 5869 extract-and-expand with SHA3-256 |
| CSPRNG | OS entropy | N bytes | `random_bytes(buf)` fills slice; `random_array::<N>()` returns `[u8; N]` — prefer `random_bytes` into a `Zeroizing` buffer for key material (see Primitives section for the Copy-type zeroization hazard) |
| Streaming AEAD | XChaCha20-Poly1305, counter-XOR nonces | 26 B header + variable chunks | 1 MiB chunks; random-access encrypt/decrypt |
| Password KDF | Argon2id (RFC 9106) | 1–4096 B output | Passphrase/key protection; not used internally by the library |

### Rust import paths

```toml
# Cargo.toml — local development
[dependencies]
libsoliton = { path = "../soliton" }

# Published form (once on crates.io — use one or the other, not both):
# libsoliton = "0.1.0"
```

**Minimum toolchain: Rust 1.93 (edition 2024).** The `unsafe extern "C" {}` block syntax added in edition 2024 is required; older toolchains produce cryptic parse errors. Run `rustup update stable` to get a recent toolchain.

**`std`-only.** soliton is not `no_std`-compatible — it uses `Vec`, `String`, `HashMap`, and OS entropy. Not suitable for bare-metal or embedded targets.

**No optional Cargo features beyond `test-utils`.** All cryptographic functionality (KEX, ratchet, storage, streaming, call keys, primitives) is unconditionally compiled. There are no feature flags to enable or disable algorithms.

**`#[must_use]`** applies to all public API functions that return key material, hashes, ciphertexts, proofs, or `Result` values — discarding the return value is a compile-time warning. Identity, auth, KEX, and ratchet sections show the attribute inline per function. The primitives section uses a blanket statement. The rule is universal: no public function's output can be silently discarded.

```rust
// Top-level modules (no re-exports — must use full paths)
use soliton::{identity, auth, kex, ratchet, storage, streaming, verification, call};
use soliton::primitives::{xwing, random, sha3_256, hmac, hkdf, aead, argon2, ed25519, x25519, mldsa, mlkem};
use soliton::constants;          // LO_PUBLIC_KEY_SIZE, XWING_PUBLIC_KEY_SIZE, STREAM_HEADER_SIZE, …
use soliton::error::{Error, Result};
use soliton::VERSION;            // &str — matches Cargo.toml version

// Types require separate imports — the module imports above bring only the module, not its types.
// Example type imports (add as needed):
use soliton::identity::{IdentityPublicKey, IdentitySecretKey, HybridSignature, GeneratedIdentity};
use soliton::ratchet::{RatchetState, EncryptedMessage, RatchetHeader};
use soliton::kex::{PreKeyBundle, VerifiedBundle, SessionInit, InitiatedSession, ReceivedSession};
use soliton::storage::{StorageKey, StorageKeyRing};
use soliton::call::CallKeys;
use soliton::streaming::{StreamEncryptor, StreamDecryptor};
use soliton::primitives::argon2::Argon2Params;
```

**`from_bytes` takes `Vec<u8>` by value (ownership transfer, not a borrow).** All key and ciphertext types use this pattern: `IdentityPublicKey`, `IdentitySecretKey`, `HybridSignature`, `xwing::PublicKey`, `xwing::SecretKey`, `xwing::Ciphertext`. Calling code that has a `&[u8]` or `Vec<u8>` must call `.to_vec()` to produce an owned copy: `SomeKey::from_bytes(slice.to_vec())?`. Callers coming from C-style APIs that pass pointers should treat this as an explicit ownership-transfer: the `Vec` is consumed (and for secret-key types, zeroized on error).

**`test-utils` cargo feature** *(for library contributors and test suite authors — not needed for application development)*: enables internal state accessors gated by `#[cfg(all(feature = "test-utils", debug_assertions))]`:
- `RatchetState`: `root_key_bytes()`, `root_key_ptr()`, `send_epoch_key_ptr()`, `recv_epoch_key_ptr()`
- `CallKeys`: `chain_key_bytes() -> &[u8; 32]`
- `InitiatedSession`: `root_key_ptr() -> *const u8`, `initial_chain_key_ptr() -> *const u8`
- `ReceivedSession`: `root_key_ptr() -> *const u8`, `initial_chain_key_ptr() -> *const u8`

All test-utils accessors carry `#[deprecated(note = "test-utils only — do not call in production code")]` — suppress with `#[allow(deprecated)]` in test code to avoid compiler warnings.

Guarded by `compile_error!` in release builds (`debug_assertions` must be set); enabling it in production is a compile-time error. Add to `[dev-dependencies]` as `libsoliton = { path = ".", features = ["test-utils"] }` — never as a regular dependency. No separate zeroed-key constructor exists — call `from_bytes` with a zero-filled `Vec<u8>` of the correct length; size validation is the only check performed.

### C API conventions

- Functions that can fail return `int32_t`: **0** = success, **negative** = error code. Cleanup and utility functions return other types as documented in each section: `_free` struct functions (`soliton_buf_free`, `soliton_encrypted_message_free`, `soliton_kex_initiated_session_free`, `soliton_kex_received_session_free`, `soliton_decoded_session_init_free`) return `void`; `soliton_zeroize` returns `void`; `soliton_version` returns `const char *`. (Handle `_free` functions — `soliton_ratchet_free`, `soliton_keyring_free`, `soliton_call_keys_free`, `soliton_stream_encrypt_free`, `soliton_stream_decrypt_free` — do return `int32_t`; see Handle free semantics below.)
- Heap-allocated output uses `SolitonBuf` — free with `soliton_buf_free`. For string-returning functions (e.g., `soliton_identity_generate`'s `fingerprint_hex_out`, `soliton_verification_phrase`), `len` **includes** the null terminator.
- Opaque handles (`SolitonRatchet *`, `SolitonKeyRing *`, etc.) must be freed with their corresponding `_free` function.
- Fixed-size output arrays (fingerprints, shared secrets, keys) are caller-allocated with documented sizes.
- **Fixed-size input pointers are not bounds-checked**: functions accepting raw pointers for fixed-size inputs (keys, nonces, fingerprints) validate only the `uintptr_t len` parameter — passing a buffer physically shorter than `len` is **undefined behavior** (out-of-bounds read), not a runtime error.
- **Thread safety**: Opaque handles are **not** thread-safe. Concurrent access to the same handle from multiple threads is **undefined behavior** (not a runtime error). Callers must serialize access externally (e.g., mutex) or use one handle per thread. Stateless functions (`sha3_256`, `hmac`, `hkdf`, `aead`, `xwing`, `identity_*`, `verification_phrase`) are safe to call concurrently with distinct buffers. **Note — streaming random-access variants**: `encrypt_chunk_at(&self, ...)` and `decrypt_chunk_at(&self, ...)` are concurrent-safe in the **Rust API** (shared `&self`, no mutation). The **CAPI** equivalents (`soliton_stream_encrypt_chunk_at`, `soliton_stream_decrypt_chunk_at`) call the reentrancy guard at entry — concurrent calls on the same handle return `SOLITON_ERR_CONCURRENT_ACCESS`, not undefined behavior. Use one handle per thread in the CAPI.
- **Handle aliasing is undefined behavior**: copying an opaque handle pointer (e.g., assigning a second variable to the same address, `memcpy` on the pointer) and then using both copies is UB, regardless of threading. For `SolitonRatchet *`, two aliases share the internal `send_count` counter — sequential encrypt calls via different aliases produce catastrophic AEAD nonce reuse. The reentrancy guard only prevents *concurrent* access, not *sequential* misuse from aliased pointers. Each handle must have exactly one owner.
- **Handle free semantics**: `_free` functions for opaque handles (`soliton_ratchet_free`, `soliton_keyring_free`, `soliton_call_keys_free`, `soliton_stream_encrypt_free`, `soliton_stream_decrypt_free`) take a double pointer (`soliton_ratchet_free(&ptr)`) and set the handle to `NULL` after freeing — double-free is a safe no-op. These functions return `int32_t`, not `void` — they can return `SOLITON_ERR_INVALID_DATA` (-17) if the magic number check fails (wrong handle type), or `SOLITON_ERR_CONCURRENT_ACCESS` (-18) if the handle is currently in use by another operation. On that error the handle is **not** freed; the caller must retry after the in-flight operation completes. **GC finalizers that run exactly once must check the return value.** **Outer null**: all five handle `_free` functions return 0 (no-op) when the outer pointer is null. Compound struct free functions (`soliton_encrypted_message_free`, `soliton_kex_initiated_session_free`, `soliton_kex_received_session_free`, `soliton_decoded_session_init_free`) take a single pointer and free internal buffers but do **not** null the caller's pointer (the struct is caller-allocated). Repeated calls to compound frees are safe because internal buffer pointers are nulled after freeing.

  Quick reference — return values from opaque handle `_free` functions (`outer NULL` = outer pointer is null; all functions null the inner pointer on success):

  | Function | Outer `NULL` | Wrong magic | In-use |
  |----------|:---:|:---:|:---:|
  | `soliton_ratchet_free` | 0 (no-op) | −17 | −18 |
  | `soliton_keyring_free` | 0 (no-op) | −17 | −18 |
  | `soliton_call_keys_free` | 0 (no-op) | −17 | −18 |
  | `soliton_stream_encrypt_free` | 0 (no-op) | −17 | −18 |
  | `soliton_stream_decrypt_free` | 0 (no-op) | −17 | −18 |

  Inner null (outer pointer is non-null but `*handle == NULL`) returns 0 for all five frees — safe to call on a zero-initialized handle variable or an already-freed handle. Outer null also returns 0 (no-op) for all five frees.

- **Handle type tagging**: Opaque handles carry an internal magic number. Passing the wrong handle type to any function (including `_free`) returns `SOLITON_ERR_INVALID_DATA` (-17). Use the correct handle with its own set of functions.
- **256 MiB input cap**: Variable-length inputs are capped at 256 MiB (268,435,456 bytes) to prevent CPU/memory DoS. Functions exceeding this cap return `SOLITON_ERR_INVALID_LENGTH` (-1). Affected: `soliton_ratchet_encrypt`, `soliton_ratchet_decrypt`, `soliton_ratchet_encrypt_first`, `soliton_ratchet_decrypt_first`, `soliton_aead_encrypt`, `soliton_aead_decrypt`, `soliton_storage_encrypt`, `soliton_storage_decrypt`, `soliton_dm_queue_encrypt`, `soliton_dm_queue_decrypt`, `soliton_sha3_256`, `soliton_hmac_sha3_256`, `soliton_hkdf_sha3_256`, `soliton_random_bytes`, `soliton_argon2id` (password and salt lengths), `soliton_stream_encrypt_init` / `soliton_stream_decrypt_init` (aad_len only).
- **Null-terminated `const char *` inputs**: most variable-length inputs use `const uint8_t * + uintptr_t len`; the following use `const char *` with no length parameter — pass a null-terminated C string; passing a non-null-terminated buffer is undefined behavior.
  - **KEX**: `crypto_version` in `soliton_kex_verify_bundle`, `soliton_kex_initiate`, `soliton_kex_encode_session_init`; also `SolitonSessionInitWire.crypto_version` (passed to `soliton_kex_receive`).
  - **Storage**: `channel_id` and `segment_id` in `soliton_storage_encrypt` / `soliton_storage_decrypt`; `batch_id` in `soliton_dm_queue_encrypt` / `soliton_dm_queue_decrypt`.
- **GC pinning**: `SolitonInitiatedSession` / `SolitonReceivedSession` contain inline secret keys (`root_key`, `initial_chain_key` / `chain_key`) — see the `WARNING` comment on the `SolitonInitiatedSession` struct definition in the KEX section before using these from managed-memory runtimes (C#, Go, Python).

```c
// Heap-allocated output buffer. Library writes ptr + len; caller frees with soliton_buf_free.
// WARNING: do NOT call free(buf->ptr) directly. ptr points into the middle of the allocation —
// the actual allocation size is stored in an internal 8-byte header at ptr-8. Passing ptr to
// free() skips the header and is undefined behavior (heap corruption or crash in the allocator).
typedef struct SolitonBuf {
    uint8_t *ptr;
    uintptr_t len;
} SolitonBuf;

void soliton_buf_free(struct SolitonBuf *buf);
// Zeroizes the entire allocation (header + data region) then frees it; sets ptr=NULL, len=0.
// Sensitive data in decrypt/keygen output buffers is automatically wiped — no manual zeroization needed before free.
// Safe to call on already-freed buf (no-op). Also safe on a zero-initialized SolitonBuf {0} — ptr=NULL skips deallocation and the function is a no-op.

void soliton_zeroize(uint8_t *ptr, uintptr_t len);
// Volatile-write zeroing — guaranteed not optimized out. Use on caller-owned buffers
// that held secret material (e.g., chain keys from soliton_ratchet_encrypt_first).
// Null ptr or zero len is a safe no-op.
```

### Type properties

All types listed are `Send + Sync` — they contain only heap-allocated or stack buffers (no raw pointers, `Rc`, or `Cell`), so `Send + Sync` auto-derive without any `unsafe impl`.

The Clone and ZeroizeOnDrop columns matter most for binding authors: **!Clone** types cannot be stored in a plain `Vec` or passed to APIs that require `Clone`; **ZeroizeOnDrop** types securely erase secret material when they leave scope.

| Type | Clone | ZeroizeOnDrop | Notes |
|------|:-----:|:-------------:|-------|
| `IdentityPublicKey` | ✓ | — | `PartialEq + Eq` via constant-time comparison |
| `IdentitySecretKey` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived |
| `HybridSignature` | ✓ | — | `PartialEq + Eq` derived |
| `GeneratedIdentity` | — | partial | No derive; `secret_key` field fires `ZeroizeOnDrop` when struct drops |
| `PreKeyBundle` | — | — | All public fields; no derive |
| `VerifiedBundle` | — | — | Newtype of `PreKeyBundle`; no derive |
| `SessionInit` | — | — | All ciphertext/public fields; no derive; see the `!Clone` note in the KEX section |
| `InitiatedSession` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived; use `take_*` methods to extract keys |
| `ReceivedSession` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived |
| `RatchetState` | — | ✓ (manual) | Manual `Drop` impl calls `reset()`, which zeroizes all key material — not `derive(ZeroizeOnDrop)` |
| `RatchetHeader` | — | — | Contains public `ratchet_pk` + optional `kem_ct`; no derive |
| `EncryptedMessage` | — | — | Contains `RatchetHeader` + `Vec<u8>` ciphertext (public); no derive |
| `StorageKey` | ✓ | ✓ | `Clone + Zeroize + ZeroizeOnDrop` derived; copying is explicit and safe |
| `StorageKeyRing` | — | ✓ | Manual `Drop` impl zeroizes each key in the map |
| `CallKeys` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived |
| `StreamEncryptor` | — | partial | No derive; `key` field is `Zeroizing<[u8; 32]>` — zeroized on drop |
| `StreamDecryptor` | — | partial | No derive; `key` field is `Zeroizing<[u8; 32]>` — zeroized on drop |
| `xwing::PublicKey` | ✓ | — | `Eq` via constant-time comparison |
| `xwing::SecretKey` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived |
| `xwing::Ciphertext` | ✓ | — | `PartialEq + Eq` derived; ciphertext is public |
| `xwing::SharedSecret` | — | ✓ | `Zeroize + ZeroizeOnDrop` derived; 32-byte `[u8; 32]` array |
| `Argon2Params` | ✓ | — | `Debug + Clone + Copy` derived; config struct, no key material |

---

## Quick-Start Example

Minimal compilable Rust — full KEX + first message + ratchet exchange in one function. Paste into `src/main.rs` with `libsoliton = { path = "../soliton" }` in `Cargo.toml`.

```rust
use soliton::constants;
use soliton::identity::{GeneratedIdentity, generate_identity};
use soliton::kex::{
    PreKeyBundle, build_first_message_aad, initiate_session,
    receive_session, sign_prekey, verify_bundle,
};
use soliton::primitives::{random, xwing};
use soliton::ratchet::RatchetState;
use soliton::storage::{StorageKey, StorageKeyRing, decrypt_blob, encrypt_blob};

fn main() -> soliton::error::Result<()> {
    // ── Identities ────────────────────────────────────────────────────────
    let GeneratedIdentity { public_key: alice_pk, secret_key: alice_sk, .. } = generate_identity()?;
    let GeneratedIdentity { public_key: bob_pk, secret_key: bob_sk, .. } = generate_identity()?;
    let fp_a = alice_pk.fingerprint_raw();
    let fp_b = bob_pk.fingerprint_raw();

    // ── Bob: build pre-key bundle ─────────────────────────────────────────
    let (spk_pk, spk_sk) = xwing::keygen()?;
    let spk_sig = sign_prekey(&bob_sk, &spk_pk)?;
    let bundle = PreKeyBundle {
        ik_pub: bob_pk.clone(),   // IdentityPublicKey: Clone
        crypto_version: constants::CRYPTO_VERSION.to_string(),
        spk_pub: spk_pk,
        spk_id: 1,
        spk_sig,
        opk_pub: None,
        opk_id: None,
    };

    // ── Alice: verify bundle, initiate session ────────────────────────────
    let verified = verify_bundle(bundle, &bob_pk)?;
    let mut initiated = initiate_session(&alice_pk, &alice_sk, &verified)?;

    // ── Alice: encrypt first message ──────────────────────────────────────
    let aad = build_first_message_aad(&fp_a, &fp_b, &initiated.session_init)?;
    let (first_ct, alice_ck) = RatchetState::encrypt_first_message(
        initiated.take_initial_chain_key(), b"hello", &aad,
    )?;

    // ── Bob: receive session, decrypt first message ───────────────────────
    // In real code Bob would decode the session_init from wire bytes:
    //   let si = kex::decode_session_init(&si_bytes)?;
    // Here both sides share the same process so we use initiated.session_init directly.
    let mut received = receive_session(
        &bob_pk, &bob_sk, &alice_pk,
        &initiated.session_init, &initiated.sender_sig,
        &spk_sk, None,
    )?;
    let (first_pt, bob_ck) = RatchetState::decrypt_first_message(
        received.take_initial_chain_key(), &first_ct, &aad,
    )?;
    assert_eq!(&*first_pt, b"hello");

    // ── Both: initialize ratchets ─────────────────────────────────────────
    // ek_pk: Clone; ek_sk: !Clone — roundtrip through bytes; peer_ek: !Clone — same.
    // *take_root_key() deref-copies Zeroizing<[u8;32]> → [u8;32] (Copy). The bare
    // copy is brief and immediately consumed by init_alice/init_bob (ZeroizeOnDrop).
    let ek_pk = initiated.ek_pk.clone();
    let ek_sk = xwing::SecretKey::from_bytes(initiated.ek_sk().as_bytes().to_vec())?;
    let peer_ek = xwing::PublicKey::from_bytes(received.peer_ek.as_bytes().to_vec())?;
    let mut alice = RatchetState::init_alice(
        *initiated.take_root_key(), *alice_ck, fp_a, fp_b, ek_pk, ek_sk,
    )?;
    let mut bob = RatchetState::init_bob(
        *received.take_root_key(), *bob_ck, fp_b, fp_a, peer_ek,
    )?;

    // ── Ratchet messages ──────────────────────────────────────────────────
    let enc = alice.encrypt(b"ratchet message")?;
    let pt = bob.decrypt(&enc.header, &enc.ciphertext)?;
    assert_eq!(&*pt, b"ratchet message");

    // ── Call keys (per-call ephemeral forward secrecy) ───────────────────
    // kem_ss: from an ephemeral X-Wing KEM exchange in the call signaling flow.
    let kem_ss = random::random_array::<32>();
    let call_id = random::random_array::<16>();
    let keys = alice.derive_call_keys(&kem_ss, &call_id)?;
    let _send_key: &[u8; 32] = keys.send_key();  // use with media encryption
    let _recv_key: &[u8; 32] = keys.recv_key();

    // ── Storage (community blob round-trip) ──────────────────────────────
    let sk = StorageKey::new(1, random::random_array::<32>())?;
    let blob = encrypt_blob(&sk, b"secret data", "channel-1", "seg-1", false)?;
    let ring = StorageKeyRing::new(sk)?;  // sk moves into ring
    let plain = decrypt_blob(&ring, &blob, "channel-1", "seg-1")?;
    assert_eq!(&*plain, b"secret data");

    println!("soliton quick-start: OK");
    Ok(())
}
```

---

## Common Pitfalls

Mistakes that compile successfully but produce incorrect or insecure behavior. All are documented in context; this table collects them for quick reference.

| # | Pitfall | What goes wrong | Where |
|---|---------|-----------------|-------|
| 1 | Using `initial_chain_key` (KEX output) as `epoch_key` for `init_alice`/`init_bob` | Silent decryption failure — both sides derive different message keys. The correct `epoch_key` is the second return value of `encrypt_first_message` / `decrypt_first_message`. | KEX Notes; Ratchet Rust API |
| 2 | Not zeroizing `root_key` / `epoch_key` after `init_alice`/`init_bob` (Rust) | `[u8; 32]` is `Copy` — `init_alice`/`init_bob` receive a copy; your stack variable still holds secret material. Call `root_key.zeroize()` immediately after the call. | Ratchet Rust API |
| 3 | Using `from_bytes` instead of `from_bytes_with_min_epoch` | No rollback protection — an attacker with access to stale blobs can substitute one silently. | Ratchet Serialize workflow |
| 4 | Passing `saved_epoch` as `min_epoch` (instead of `saved_epoch - 1`) | Rejects the blob you just wrote — `from_bytes_with_min_epoch` uses strict greater-than. | Ratchet Serialize workflow |
| 5 | Calling Rust `to_bytes()` without `can_serialize()` on a counter-maxed ratchet | `to_bytes()` moves `self`; on `ChainExhausted` the ratchet is dropped. Call `can_serialize()` first and handle `false` before calling `to_bytes()`. | Ratchet Serialize workflow |
| 6 | Calling `free()` directly on `SolitonBuf.ptr` (CAPI) | `ptr` points into the middle of the allocation — `free()` on it skips the internal 8-byte header and is heap corruption. Use `soliton_buf_free()` exclusively. | C API conventions |
| 7 | Aliasing a `SolitonRatchet *` pointer (CAPI) | Sequential `encrypt` calls via two aliases reuse AEAD nonces — catastrophic ciphertext collision. One handle, one owner. | C API conventions |
| 8 | Assuming `soliton_ratchet_to_bytes` nulls the handle on `CHAIN_EXHAUSTED` (CAPI) | On `CHAIN_EXHAUSTED`, `*ratchet` is NOT nulled — the session is still usable. Send/receive more messages, then retry. Do not call `_free` on this error. | Ratchet Serialize workflow |
| 9 | Expecting Bob's first `encrypt()` to skip the KEM step | `init_bob` always sets `ratchet_pending = true`. Bob's first outgoing message always carries a `kem_ct` in the header. Transport framing must allow for an optional ciphertext on every message. | Ratchet Notes |
| 10 | Passing an all-zero key to `StorageKey::new` or `soliton_keyring_new` | `InvalidData` at construction — not silently at encryption. Ensure key material comes from a CSPRNG or KDF, not a zero-initialized buffer. | All-zero rejection inventory |
| 11 | Not zeroizing the `[u8; 32]` source buffer after `StorageKey::new` | `[u8; 32]` is `Copy` — `StorageKey::new()` received a bitwise copy; your stack variable still holds key material. Call `key.zeroize()` immediately after the call. | Storage API |

---

## Error Handling

| Variant / Name | Code | C Constant | Meaning |
|---------|------|------------|---------|
| *(success)* | 0 | `SOLITON_OK` | |
| `InvalidLength { expected: usize, got: usize }` | -1 | `SOLITON_ERR_INVALID_LENGTH` | Input buffer had wrong size; `expected` is either an exact required size or (for length-prefix bounded fields) the maximum allowed size |
| `DecapsulationFailed` | -2 | `SOLITON_ERR_DECAPSULATION` | KEM decapsulation failed (invalid ciphertext) |
| `VerificationFailed` | -3 | `SOLITON_ERR_VERIFICATION` | Signature verification failed (Ed25519, ML-DSA-65, or hybrid) |
| `AeadFailed` | -4 | `SOLITON_ERR_AEAD` | AEAD decryption failed (wrong key, tampered ciphertext, or wrong AAD) |
| `BundleVerificationFailed` | -5 | `SOLITON_ERR_BUNDLE` | Pre-key bundle verification failed (IK mismatch or invalid SPK signature) |
| `TooManySkipped` | -6 | `SOLITON_ERR_TOO_MANY_SKIPPED` | *(reserved — not currently returned; see footnote)* |
| `DuplicateMessage` | -7 | `SOLITON_ERR_DUPLICATE` | Message already decrypted or behind receiver's counter |
| *(retired)* | -8 | `SOLITON_ERR_SKIPPED_KEY` *(removed)* | Retired legacy code — gap preserved for ABI stability; never returned |
| `AlgorithmDisabled` | -9 | `SOLITON_ERR_ALGORITHM` | *(reserved — not currently returned; see footnote)* |
| `UnsupportedVersion` | -10 | `SOLITON_ERR_VERSION` | Serialized blob has unsupported version |
| `DecompressionFailed` | -11 | `SOLITON_ERR_DECOMPRESSION` | *(reserved — not currently returned; see footnote)* |
| `Internal` | -12 | `SOLITON_ERR_INTERNAL` | Structurally unreachable internal invariant violated (bug in soliton) |
| `NullPointer` | -13 | `SOLITON_ERR_NULL_POINTER` | Null pointer passed for a required argument *(CAPI-only)* |
| `UnsupportedFlags` | -14 | `SOLITON_ERR_FLAGS` | *(reserved — not currently returned; see footnote)* |
| `ChainExhausted` | -15 | `SOLITON_ERR_CHAIN_EXHAUSTED` | Counter-space exhausted. Sources: ratchet send/recv counter reaches u32::MAX; call key chain advance limit (2²⁴ = 16,777,216 steps); per-epoch recv_seen set reaches 65536 distinct entries; streaming chunk index reaches u64::MAX |
| `UnsupportedCryptoVersion` | -16 | `SOLITON_ERR_CRYPTO_VERSION` | Peer advertised a crypto version this library does not implement |
| `InvalidData` | -17 | `SOLITON_ERR_INVALID_DATA` | Structurally invalid content (format error, co-presence violation, implausible values) |
| `ConcurrentAccess` | -18 | `SOLITON_ERR_CONCURRENT_ACCESS` | Opaque handle is in use by another concurrent operation (reentrancy guard) *(CAPI-only)* |

**`NullPointer` and `ConcurrentAccess` are `SolitonError` variants (CAPI-only enum in `soliton_capi`) — they have no `soliton::Error` equivalent and cannot appear in a `match` on `Err(Error::...)`.**

**`Error` implements `std::error::Error + Display`** (via `thiserror`) — usable with `?` into `Box<dyn std::error::Error>`, `eprintln!("{}", e)`, and any standard error-reporting infrastructure.

**`Error` is `#[non_exhaustive]`** — exhaustive `match` arms will fail to compile when new variants are added; always include a wildcard:

```rust
match ratchet.encrypt(plaintext) {
    Ok(msg)                    => send(msg),
    Err(Error::ChainExhausted) => rekey(),
    Err(e)                     => return Err(e),  // wildcard required — Error is #[non_exhaustive]
}
```

**Reserved variants footnote:** `-6 TooManySkipped`, `-9 AlgorithmDisabled`, `-11 DecompressionFailed`, and `-14 UnsupportedFlags` exist for ABI stability but are not currently constructed by any code path. `TooManySkipped` was a pre-counter-mode artefact with no corresponding failure mode in the current implementation. `AlgorithmDisabled` has no active code paths. `DecompressionFailed` and `UnsupportedFlags` are intentionally collapsed to `AeadFailed` (oracle prevention — a distinct error would let an attacker distinguish a bad ciphertext from a valid-but-oversized one). `-8` is a permanently retired legacy code (`SOLITON_ERR_SKIPPED_KEY`, removed); the gap is preserved to avoid changing the meaning of any persisted error codes.


**Note:** `soliton_storage_decrypt` and `soliton_dm_queue_decrypt` collapse all internal failures (decompression, unsupported flags, invalid data, version mismatch) to `SOLITON_ERR_AEAD` (-4) to prevent error-oracle attacks. Reachable error codes from these functions: `SOLITON_ERR_NULL_POINTER` (-13), `SOLITON_ERR_CONCURRENT_ACCESS` (-18), `SOLITON_ERR_INVALID_LENGTH` (-1, blob size out of bounds; also `recipient_fp_len != 32` for `soliton_dm_queue_decrypt`), `SOLITON_ERR_INVALID_DATA` (-17, invalid UTF-8 in channel/segment/batch ID, or magic check failure — means a wrong opaque handle type was passed, e.g. a `SolitonRatchet *` where a `SolitonKeyRing *` is expected; there is only one keyring type, so passing a community blob to `soliton_dm_queue_decrypt` is not detected here — wrong AAD causes `SOLITON_ERR_AEAD`), `SOLITON_ERR_AEAD` (-4, all other failures collapsed).

### All-zero rejection inventory

Several functions perform constant-time all-zero checks on secret key inputs. All-zero values indicate uninitialized buffers or programming errors and would produce predictable key material — treating them as valid is a silent security failure.

**Functions that reject all-zero inputs:**

| Function | Rejected parameter(s) | Error | Rationale |
|----------|-----------------------|-------|-----------|
| `RatchetState::init_alice` | `root_key` or `epoch_key` | `InvalidData` | HKDF output; all-zero signals a programming error. Check is constant-time (secret material). |
| `RatchetState::init_alice` | `local_fp` or `remote_fp` | `InvalidData` | All-zero fingerprint cannot roundtrip through ratchet serialization. |
| `RatchetState::init_bob` | `root_key` or `epoch_key` | `InvalidData` | Same as `init_alice`. |
| `RatchetState::init_bob` | `local_fp` or `remote_fp` | `InvalidData` | Same as `init_alice`. |
| `RatchetState::encrypt` / `decrypt` / `to_bytes` | internal `root_key` | `InvalidData` | Session-dead guard — `root_key` is zeroed by `reset()` and after any encrypt/decrypt error. Returning `InvalidData` rather than `Internal` signals recoverable state (re-establish session via KEX). |
| `RatchetState::from_bytes` / `from_bytes_with_min_epoch` | parsed `root_key` | `InvalidData` | Deserialization guard — rejects blobs that were serialized from a dead (post-reset) ratchet. |
| `StorageKey::new` | `key` | `InvalidData` | All-zero XChaCha20-Poly1305 key provides no confidentiality. Check is constant-time. |
| `derive_call_keys` | `root_key` | `InvalidData` | All-zero root key removes the ratchet binding from call keys. Check is constant-time. |
| `derive_call_keys` | `kem_ss` | `InvalidData` | All-zero KEM shared secret loses ephemeral forward secrecy. Check is constant-time. |
| `derive_call_keys` | `call_id` | `InvalidData` | All-zero call ID indicates an uninitialized buffer — variable-time check (call_id is non-secret). |

**Functions that do NOT reject all-zero (size check only):** `IdentityPublicKey::from_bytes`, `IdentitySecretKey::from_bytes`, `HybridSignature::from_bytes`, `xwing::PublicKey::from_bytes`, `xwing::SecretKey::from_bytes`, `xwing::Ciphertext::from_bytes`. These accept any bytes of the correct length — validation only happens when the key or ciphertext is actually used in a cryptographic operation.

### Error recovery

What to do when each error variant is returned. "Re-establish" means start a new KEX from the beginning.

| Error | Typical cause | Recommended action |
|-------|--------------|-------------------|
| `AeadFailed` (-4) | Wrong decryption key; tampered or replayed ciphertext; wrong AAD; storage internal failure (collapsed) | Drop the message or blob. If persistent on a live ratchet, re-establish session. |
| `BundleVerificationFailed` (-5) | SPK signature invalid; identity key mismatch in bundle | Reject bundle. Do not proceed with KEX. Alert user — the key server or transport may be compromised. |
| `ChainExhausted` (-15) | Ratchet or call-key counter reached its limit; streaming chunk index at `u64::MAX` | Re-establish session via new KEX. For streaming, start a new stream. |
| `DuplicateMessage` (-7) | Replayed or already-seen message counter | Drop silently. Replay attacks are not a reason to tear down the session. |
| `VerificationFailed` (-3) | Invalid hybrid signature (Ed25519 or ML-DSA-65 component) | Reject the signed object. Do not use any data derived from it. |
| `DecapsulationFailed` (-2) | Invalid KEM ciphertext | Reject. Do not retry with the same ciphertext. Re-establish session if this is a session-init message. |
| `InvalidData` (-17) | Malformed blob; all-zero key passed to init; co-presence violation; wrong handle type (CAPI) | Fix the caller. If an all-zero key triggered this, it is a programming error — do not retry without understanding why the key was zero. |
| `InvalidLength` (-1) | Buffer passed with wrong size; length exceeds 256 MiB cap | Fix the caller. Check sizes against the constants table. |
| `UnsupportedVersion` (-10) | Serialized blob written by a newer library version | Upgrade the library, or discard the blob if downgrade is required. No migration path — re-establish session. |
| `UnsupportedCryptoVersion` (-16) | Peer advertises a `crypto_version` string this library does not recognize | Reject the peer. Update the library, or reject the connection. |
| `Internal` (-12) | Library invariant violated (should be unreachable) | Treat as fatal. File a bug report with reproduction steps. |
| `NullPointer` (-13) *(CAPI-only)* | Null pointer passed for a required argument | Fix the caller. Always check CAPI return codes before using output buffers. |
| `ConcurrentAccess` (-18) *(CAPI-only)* | Opaque handle already in use by another operation (reentrancy guard) | Retry after the in-flight operation completes. Use a mutex to serialize handle access across threads. The handle is **not** freed — do not call `_free` on an `ConcurrentAccess` error. |

### Size constants

**C constants** (`soliton.h` `#define`): `SOLITON_*_SIZE`
**Rust constants** (`soliton::constants`): see the full table below — all C `SOLITON_*_SIZE` names have a `constants::` Rust equivalent (e.g. `SOLITON_PUBLIC_KEY_SIZE` → `LO_PUBLIC_KEY_SIZE`). Prefer constants over hardcoded literals in allocation code.

| C Constant | Rust Constant | Value | Description |
|------------|---------------|-------|-------------|
| `SOLITON_PUBLIC_KEY_SIZE` | `constants::LO_PUBLIC_KEY_SIZE` | 3200 | IdentityPublicKey (X-Wing 1216 + Ed25519 32 + ML-DSA-65 1952) |
| `SOLITON_SECRET_KEY_SIZE` | `constants::LO_SECRET_KEY_SIZE` | 2496 | IdentitySecretKey (X-Wing 2432 + Ed25519 32 + ML-DSA-65 seed 32) |
| `SOLITON_XWING_PK_SIZE` | `constants::XWING_PUBLIC_KEY_SIZE` | 1216 | X-Wing public key |
| `SOLITON_XWING_SK_SIZE` | `constants::XWING_SECRET_KEY_SIZE` | 2432 | X-Wing secret key |
| `SOLITON_XWING_CT_SIZE` | `constants::XWING_CIPHERTEXT_SIZE` | 1120 | X-Wing ciphertext |
| `SOLITON_ED25519_SIG_SIZE` | `constants::ED25519_SIGNATURE_SIZE` | 64 | Ed25519 signature |
| `SOLITON_HYBRID_SIG_SIZE` | `constants::HYBRID_SIGNATURE_SIZE` | 3373 | Hybrid signature (Ed25519 64 + ML-DSA-65 3309) |
| `SOLITON_MLDSA_SIG_SIZE` | `constants::MLDSA_SIGNATURE_SIZE` | 3309 | ML-DSA-65 signature |
| `SOLITON_SHARED_SECRET_SIZE` | `constants::SHARED_SECRET_SIZE` | 32 | X-Wing / KEM shared secret |
| `SOLITON_FINGERPRINT_SIZE` | `constants::FINGERPRINT_SIZE` | 32 | SHA3-256 fingerprint |
| `SOLITON_AEAD_TAG_SIZE` | `constants::AEAD_TAG_SIZE` | 16 | XChaCha20-Poly1305 tag |
| `SOLITON_AEAD_NONCE_SIZE` | `constants::AEAD_NONCE_SIZE` | 24 | XChaCha20-Poly1305 nonce |
| `SOLITON_CALL_ID_SIZE` | `constants::CALL_ID_SIZE` | 16 | Call ID |
| `SOLITON_STREAM_HEADER_SIZE` | `constants::STREAM_HEADER_SIZE` | 26 | Streaming AEAD header (version + flags + 24-B nonce) |
| `SOLITON_STREAM_CHUNK_SIZE` | `constants::STREAM_CHUNK_SIZE` | 1,048,576 | Streaming plaintext chunk size (1 MiB) |
| `SOLITON_STREAM_ENCRYPT_MAX` | `constants::STREAM_ENCRYPT_MAX` | 1,048,849 | Max encrypted chunk (`STREAM_CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + STREAM_CHUNK_OVERHEAD`) |
| *(Rust only — no `#define` in `soliton.h`)* | | | |
| — | `constants::STREAM_CHUNK_OVERHEAD` | 17 | Per-chunk overhead (tag\_byte + 16-B Poly1305 tag) |
| — | `constants::STREAM_ZSTD_OVERHEAD` | 256 | Worst-case zstd expansion per chunk |
| — | `constants::STREAM_VERSION` | `0x01` | Streaming format version byte |
| — | `constants::CRYPTO_VERSION` | `"lo-crypto-v1"` | Protocol version string; use in `PreKeyBundle.crypto_version` and wire fields. Exact string match — `verify_bundle`, `receive_session`, and `decode_session_init` all reject any value that is not byte-for-byte `"lo-crypto-v1"` (no prefix or version-range comparison). |
| — | `constants::RATCHET_BLOB_VERSION` | `0x01` | Version byte in serialized ratchet blobs; `from_bytes*` returns `UnsupportedVersion` if blob version ≠ this value. No migration path — old-version blobs are permanently unreadable; re-establish sessions via new KEX. |

### Protocol domain labels (Rust only — `soliton::constants`)

Used internally by the library as HKDF info strings, HMAC labels, AAD prefixes, and signature context strings. Listed here for implementors producing wire-compatible code or verifying cross-implementation interoperability.

All constants have type `&[u8]` (byte-string literals). In Rust, use `b"..."` notation or reference the constant directly — **not** `&str`. `constants::AUTH_HMAC_LABEL == b"lo-auth-v1"` compiles; `== "lo-auth-v1"` does not.

| Constant | Value (`&[u8]`) | Used in |
|----------|-----------------|---------|
| `AUTH_HMAC_LABEL` | `b"lo-auth-v1"` | KEM authentication HMAC (§4.2) |
| `KEX_HKDF_INFO_PFX` | `b"lo-kex-v1"` | LO-KEX HKDF key derivation (§5.4) |
| `SPK_SIG_LABEL` | `b"lo-spk-sig-v1"` | SPK signature context (§5.3) |
| `INITIATOR_SIG_LABEL` | `b"lo-kex-init-sig-v1"` | SessionInit signature context (§5.4) |
| `RATCHET_HKDF_INFO` | `b"lo-ratchet-v1"` | Ratchet root KDF (§6.4) |
| `DM_AAD` | `b"lo-dm-v1"` | DM message AAD prefix (§7.3) |
| `STORAGE_AAD` | `b"lo-storage-v1"` | Community storage AAD (§11.4.1) |
| `DM_QUEUE_AAD` | `b"lo-dm-queue-v1"` | DM queue storage AAD (§11.4.2) |
| `CALL_HKDF_INFO` | `b"lo-call-v1"` | Call key HKDF derivation (§6.12) |
| `PHRASE_HASH_LABEL` | `b"lo-verification-v1"` | Verification phrase initial hash |
| `PHRASE_EXPAND_LABEL` | `b"lo-phrase-expand-v1"` | Verification phrase expansion rehash |
| `STREAM_AAD` | `b"lo-stream-v1"` | Streaming AEAD domain label (§15) |

**Single-byte and fixed-value domain constants** (also in `soliton::constants`, required for reimplementation):

| Constant | Type | Value | Used in |
|----------|------|-------|---------|
| `MSG_KEY_DOMAIN_BYTE` | `u8` | `0x01` | Ratchet counter-mode message key: `HMAC(epoch_key, 0x01 \|\| counter_BE)` where `counter_BE` is 4 bytes (u32 big-endian) — full input is 5 bytes |
| *(0x02, 0x03 reserved)* | — | — | Deliberately unassigned — gap between ratchet byte (0x01) and call bytes (0x04–0x06); reserved to prevent accidental reuse of former chain-mode derivation bytes. New protocol derivations must use 0x07 or higher. |
| `CALL_KEY_A_BYTE` | `&[u8]` | `&[0x04]` | Call key derivation, first call key |
| `CALL_KEY_B_BYTE` | `&[u8]` | `&[0x05]` | Call key derivation, second call key |
| `CALL_CHAIN_ADV_BYTE` | `&[u8]` | `&[0x06]` | Call chain key advance |
| `HKDF_ZERO_SALT` | `[u8; 32]` | `[0u8; 32]` | LO-KEX HKDF salt (§5.4 Step 4) — 32 zero bytes |
| `MAX_RECV_SEEN` | `u32` | `65536` | recv_seen set cap per epoch; 65536th distinct-counter decrypt succeeds, 65537th returns `ChainExhausted` |
| `MAX_CALL_ADVANCE` *(internal, not pub)* | `u32` | `16,777,216` (2²⁴) | `CallKeys::advance()` limit; `ChainExhausted` after this many calls. Not in `soliton::constants` — value for reimplementors only. |

---

## Identity

Generates, signs with, and verifies using LO composite identity keys (X-Wing + ML-DSA-65). Provides KEM encapsulation/decapsulation for identity-bound key agreement.

| Type | Size (bytes) |
|------|-------------|
| IdentityPublicKey | 3200 (X-Wing pk 1216 + Ed25519 pk 32 + ML-DSA-65 pk 1952) |
| IdentitySecretKey | 2496 (X-Wing sk 2432 + Ed25519 sk 32 + ML-DSA-65 seed 32) |
| Fingerprint (raw) | 32 (SHA3-256 of public key bytes) |
| Fingerprint (hex) | 64 (lowercase hex) |
| HybridSignature | 3373 (Ed25519 64 + ML-DSA-65 3309) |
| X-Wing Ciphertext | 1120 (X25519 ephemeral 32 + ML-KEM-768 ct 1088) |
| X-Wing Shared Secret | 32 |

### Rust API

```rust
pub struct GeneratedIdentity {
    pub public_key:      IdentityPublicKey,  // composite public key (3200 B); NOT zeroized on drop (non-secret)
    pub secret_key:      IdentitySecretKey,  // composite secret key (2496 B); zeroized on drop
    pub fingerprint_hex: String,             // lowercase hex SHA3-256 of public key (64 chars)
}

#[must_use = "identity key material must not be discarded"]
pub fn generate_identity() -> Result<GeneratedIdentity>

pub fn hybrid_sign(sk: &IdentitySecretKey, message: &[u8]) -> Result<HybridSignature>

#[must_use = "signature verification result must be checked"]
pub fn hybrid_verify(pk: &IdentityPublicKey, message: &[u8], sig: &HybridSignature) -> Result<()>

pub fn encapsulate(pk: &IdentityPublicKey) -> Result<(xwing::Ciphertext, xwing::SharedSecret)>

pub fn decapsulate(sk: &IdentitySecretKey, ct: &xwing::Ciphertext) -> Result<xwing::SharedSecret>

// IdentityPublicKey: Clone — can clone freely (not secret).
// IdentitySecretKey: !Clone + ZeroizeOnDrop — cannot clone; to create an owned copy:
//   IdentitySecretKey::from_bytes(sk.as_bytes().to_vec())
// HybridSignature: Clone — can clone freely (not secret).
impl IdentityPublicKey {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 3200
    // Size-only check — sub-key structure (X-Wing, Ed25519, ML-DSA-65) validated lazily at use (encapsulate, hybrid_verify). A malformed or all-zero key won't error until those calls.
    pub fn fingerprint_hex(&self) -> String
    pub fn fingerprint_raw(&self) -> [u8; 32]
    pub fn x25519_pk(&self) -> &[u8]        // bytes [0..32]
    pub fn mlkem_pk(&self) -> &[u8]         // bytes [32..1216] (1184 bytes)
    pub fn xwing_pk(&self) -> &[u8]         // bytes [0..1216] = x25519_pk() || mlkem_pk()
    pub fn ed25519_pk(&self) -> &[u8]       // bytes [1216..1248]
    pub fn mldsa_pk(&self) -> &[u8]         // bytes [1248..3200]
}

impl IdentitySecretKey {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 2496
    // On Err, the input Vec is zeroized before returning (wrapped in Zeroizing internally).
    // Size-only check — sub-key structure validated lazily at use (hybrid_sign, decapsulate).
    pub fn x25519_sk(&self) -> &[u8]     // bytes [0..32]
    pub fn xwing_sk(&self) -> &[u8]     // bytes [0..2432] = x25519_sk (32) || ML-KEM secret key (2400)
    pub fn ed25519_sk(&self) -> &[u8]   // bytes [2432..2464]
    pub fn mldsa_sk(&self) -> &[u8]     // bytes [2464..2496] — 32-byte seed ξ (FIPS 204 §6.1), NOT the expanded private key
}

impl HybridSignature {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 3373
    pub fn ed25519_sig(&self) -> &[u8]  // bytes [0..64]
    pub fn mldsa_sig(&self) -> &[u8]   // bytes [64..3373]
}
```

### C API

```c
int32_t soliton_identity_generate(struct SolitonBuf *pk_out,
                                    struct SolitonBuf *sk_out,
                                    struct SolitonBuf *fingerprint_hex_out);

int32_t soliton_identity_fingerprint(const uint8_t *pk, uintptr_t pk_len,
                                       uint8_t *out, uintptr_t out_len);  // out: 32 bytes raw; no CAPI hex form — encode to hex in the application layer if needed

int32_t soliton_identity_sign(const uint8_t *sk, uintptr_t sk_len,
                                const uint8_t *message, uintptr_t message_len,
                                struct SolitonBuf *sig_out);

int32_t soliton_identity_verify(const uint8_t *pk, uintptr_t pk_len,
                                  const uint8_t *message, uintptr_t message_len,
                                  const uint8_t *sig, uintptr_t sig_len);

int32_t soliton_identity_encapsulate(const uint8_t *pk, uintptr_t pk_len,
                                       struct SolitonBuf *ct_out,
                                       uint8_t *ss_out, uintptr_t ss_out_len);  // ss_out: 32 bytes

int32_t soliton_identity_decapsulate(const uint8_t *sk, uintptr_t sk_len,
                                       const uint8_t *ct, uintptr_t ct_len,
                                       uint8_t *ss_out, uintptr_t ss_out_len);  // ss_out: 32 bytes
```

### Notes

- Fingerprint is SHA3-256 of the raw public key bytes; returned as 64-char lowercase hex from `fingerprint_hex()` or 32 raw bytes from `fingerprint_raw()`.
- HybridSignature requires **both** Ed25519 and ML-DSA-65 components to verify; verification is unconditional (no short-circuit) to prevent timing side-channels.
- Encapsulation/decapsulation use only the X-Wing component; ML-DSA is signing-only.
- Secret keys are wrapped in `Zeroizing<T>` and automatically zeroized on drop.
- `IdentityPublicKey` implements `PartialEq` via `subtle::ConstantTimeEq` (constant-time) and derives `Eq`. Does **not** implement `Hash` — cannot be used directly in `HashSet`/`HashMap` without a custom wrapper.
- `HybridSignature` derives standard `PartialEq + Eq` (variable-time, same as `Vec<u8>`). Does **not** implement `Hash` — not directly usable in `HashSet`/`HashMap`. Signatures are not secret, so timing risk is negligible; use `hybrid_verify` for any signature-verification path rather than `==`. **Do not use `==` to verify**: ML-DSA uses hedged (randomized) signing — two calls to `hybrid_sign` on the same input produce different byte sequences, so `sig_a == sig_b` is always false even for the same message and key. Only `hybrid_verify` can validate a signature.

---

## Auth

KEM-based authentication proving possession of a LO identity private key via X-Wing encapsulation and HMAC-SHA3-256 proof.

| Type | Size (bytes) |
|------|-------------|
| Auth Challenge (ciphertext) | 1120 (X-Wing CT) |
| Auth Token / Proof | 32 (HMAC-SHA3-256) |

### Rust API

```rust
#[must_use = "contains secret token material that must not be silently discarded"]
pub fn auth_challenge(client_pk: &IdentityPublicKey)
    -> Result<(xwing::Ciphertext, Zeroizing<[u8; 32]>)>
// Returns (ciphertext_to_send, expected_token_to_store)

#[must_use = "contains secret proof material that must not be silently discarded"]
pub fn auth_respond(client_sk: &IdentitySecretKey, ct: &xwing::Ciphertext)
    -> Result<Zeroizing<[u8; 32]>>
// Returns proof_to_send

#[must_use]
pub fn auth_verify(expected_token: &[u8; 32], proof: &[u8; 32]) -> bool
// Constant-time comparison
```

### C API

```c
int32_t soliton_auth_challenge(const uint8_t *client_pk, uintptr_t client_pk_len,
                                 struct SolitonBuf *ct_out,
                                 uint8_t *token_out, uintptr_t token_out_len);  // token_out: 32 bytes

int32_t soliton_auth_respond(const uint8_t *client_sk, uintptr_t client_sk_len,
                               const uint8_t *ct, uintptr_t ct_len,
                               uint8_t *proof_out, uintptr_t proof_out_len);  // proof_out: 32 bytes

int32_t soliton_auth_verify(const uint8_t *expected_token, uintptr_t expected_token_len,
                              const uint8_t *proof, uintptr_t proof_len);
// Returns 0 if valid, -3 (SOLITON_ERR_VERIFICATION) if invalid.
// Returns -13 (SOLITON_ERR_NULL_POINTER) if either pointer is null.
// Returns -1 (SOLITON_ERR_INVALID_LENGTH) if either length ≠ 32.
```

### Notes

- Server calls `auth_challenge(client_pk)` → stores token, sends ciphertext to client.
- Client calls `auth_respond(client_sk, ct)` → sends proof to server.
- Server calls `auth_verify(token, proof)` — constant-time; returns `true`/0 if valid.
- Token and proof are `HMAC-SHA3-256(key=shared_secret, data=b"lo-auth-v1")` — the X-Wing shared secret is the HMAC key; the label is the data.
- X-Wing shared secret is zeroized immediately after HMAC computation.
- **Zeroizing Copy hazard**: `auth_challenge` and `auth_respond` return `Zeroizing<[u8; 32]>`, which wraps a `Copy` type. Dereferencing (`let raw: [u8; 32] = *token`) creates an unprotected copy that is not zeroized on drop. Access bytes via `&*token` or `token.as_ref()`.
- **Freshness is the caller's responsibility.** The challenge/proof scheme is stateless — the library does not enforce single-use or expiry. The server must bind the challenge to a session (e.g., include a session nonce, enforce timeout, and reject replayed ciphertexts) to prevent replay attacks across servers or sessions.

---

## Verification

Generates human-readable safety-number phrases for out-of-band identity verification.

### Rust API

```rust
#[must_use = "verification phrase must be displayed to the user"]
pub fn verification_phrase(pk_a: &[u8], pk_b: &[u8]) -> Result<String>
```

### C API

```c
int32_t soliton_verification_phrase(const uint8_t *pk_a, uintptr_t pk_a_len,
                                      const uint8_t *pk_b, uintptr_t pk_b_len,
                                      struct SolitonBuf *phrase_out);
```

### Notes

- Returns 7 space-separated words from the EFF large wordlist (~90 bits of second-preimage resistance).
- Deterministic and order-independent: `phrase(a, b) == phrase(b, a)`.
- Returns `InvalidLength` if either input is not exactly 3200 bytes — passing a 32-byte fingerprint instead of a full public key is the common mistake.
- Returns `InvalidData` if `pk_a == pk_b` (same key passed twice — collapsed roles give no security).
- Returns `Internal` in astronomically improbable degenerate cases (≈ 2⁻¹⁵⁰); treat as a hard error.
- Both parties independently generate and compare out-of-band to verify identity key continuity. A mismatch indicates identity key substitution or a MITM — abort the session; do not continue communicating.
- **Hash construction and word selection** (for reimplementors): `SHA3-256(b"lo-verification-v1" || lower_key || higher_key)` where `lower_key`/`higher_key` are the inputs sorted lexicographically (byte-by-byte `<=`). Word selection from the 32-byte hash: read two bytes at a time as big-endian u16 (16 pairs per hash); reject any value ≥ 62208 (= 7776 × 8) to eliminate modular bias; word index = `accepted_val % 7776` into the EFF large wordlist (exactly 7776 entries, generated by `build.rs` at compile time). When the hash is exhausted before 7 words are collected, expand: `SHA3-256(b"lo-phrase-expand-v1" || round_byte || previous_hash)` where `round_byte` is a u8 counter starting at `1`. At most 19 rehash rounds (20 total rounds × 16 pairs = 320 samples; failure probability < 2⁻¹⁵⁰); returns `Internal` if exceeded (structurally unreachable).

---

## KEX — LO-KEX Asynchronous Key Exchange

KEM-based asynchronous key exchange (similar to X3DH but post-quantum). Produces shared root key + chain key ready to initialize a LO-Ratchet session.

**Protocol overview:**
- Two parties: **Alice** (initiator) and **Bob** (responder), each with a long-term identity key pair (IK)
- Key material: IK (identity key), SPK (signed prekey), OPK (optional one-time prekey)
- SPK and OPK are plain X-Wing keypairs (`xwing::keygen()` / `soliton_xwing_keygen()`); only the SPK requires signing via `sign_prekey()` / `soliton_kex_sign_prekey()`
- Output: `root_key` (32 B) + `initial_chain_key` (32 B) + `peer_ek` (1216 B) for ratchet initialization

### KEX-specific sizes

All key and signature sizes are in the global constants table (see Size constants section). KEX-only:

| Type | Size (bytes) | Notes |
|------|-------------|-------|
| SPK ID / OPK ID | 4 | Big-endian u32 |
| SessionInit wire (no OPK) | 3543 | |
| SessionInit wire (with OPK) | 4669 | |

### Rust API

```rust
// Pre-key bundle types
pub struct PreKeyBundle {
    pub ik_pub: IdentityPublicKey,
    pub crypto_version: String,
    pub spk_pub: xwing::PublicKey,
    pub spk_id: u32,
    pub spk_sig: HybridSignature,
    pub opk_pub: Option<xwing::PublicKey>,
    pub opk_id: Option<u32>,
}
pub struct VerifiedBundle(PreKeyBundle);  // newtype: bundle passed verify_bundle — inner field is private; external code cannot construct one directly, so the type system enforces that verify_bundle() cannot be skipped
// VerifiedBundle implements Deref<Target = PreKeyBundle> — access fields directly:
// verified.ik_pub, verified.spk_pub, verified.spk_id, verified.crypto_version, etc.

// Pre-key signing (Bob)
pub fn sign_prekey(ik_sk: &IdentitySecretKey, spk_pub: &xwing::PublicKey)
    -> Result<HybridSignature>

// Bundle verification (Alice, before initiating)
#[must_use = "bundle verification result must be checked"]
pub fn verify_bundle(bundle: PreKeyBundle, known_ik: &IdentityPublicKey)
    -> Result<VerifiedBundle>
// Err(InvalidData) — OPK co-presence violation (opk_pub present without opk_id, or vice versa).
// Err(BundleVerificationFailed) — IK does not match known_ik; crypto_version not recognized;
//   or SPK signature invalid. All three cases return the same error to avoid exposing
//   which check failed — an oracle distinguishing IK-mismatch from bad-sig would allow
//   iterative probing.

// App-layer obligation — TOFU: verify_bundle and receive_session verify that a signature
// matches the provided public key, but they do NOT verify who owns the key. The application
// MUST persist the peer's identity key fingerprint (SHA3-256(ik_pub)) on first contact and
// warn the user if it changes on subsequent sessions. Without this, an attacker who substitutes
// their own identity key produces a valid signature that the library will accept.

// Initiator side (Alice)
#[must_use = "session establishment result contains key material that must not be discarded"]
pub fn initiate_session(
    alice_ik_pk: &IdentityPublicKey,
    alice_ik_sk: &IdentitySecretKey,
    bundle: &VerifiedBundle,
) -> Result<InitiatedSession>

// pub fields (InitiatedSession implements ZeroizeOnDrop — Rust forbids partial moves out of Drop types):
//   pub session_init: SessionInit   — reference only (&session.session_init); SessionInit: !Clone
//   pub sender_sig: HybridSignature — Clone freely (HybridSignature: Clone), or reference via &session.sender_sig; partial move is what's forbidden, not Clone
//   pub ek_pk: xwing::PublicKey     — Clone, or .as_bytes().to_vec() → PublicKey::from_bytes()
//   pub opk_used: bool              — Copy; true if OPK was consumed; signal to replenish server OPKs
impl InitiatedSession {
    pub fn take_root_key(&mut self) -> Zeroizing<[u8; 32]>         // destructive — second call returns zeros (init_alice rejects as InvalidData)
    pub fn take_initial_chain_key(&mut self) -> Zeroizing<[u8; 32]> // destructive — second call returns zeros (encrypt_first_message will produce wrong key; init_alice rejects as InvalidData)
    pub fn ek_sk(&self) -> &xwing::SecretKey     // accessor — ek_sk field is private; SecretKey is !Clone
}

pub struct SessionInit {
    pub crypto_version: String,
    pub sender_ik_fingerprint: [u8; 32],
    pub recipient_ik_fingerprint: [u8; 32],
    pub sender_ek: xwing::PublicKey,           // Alice's ephemeral X-Wing pk (1216 B)
    pub ct_ik: xwing::Ciphertext,              // 1120 B — encapsulated to Bob's IK
    pub ct_spk: xwing::Ciphertext,             // 1120 B — encapsulated to Bob's SPK
    pub spk_id: u32,
    pub ct_opk: Option<xwing::Ciphertext>,     // 1120 B; None if no OPK used
    pub opk_id: Option<u32>,
}
// SessionInit: !Clone — no Clone derive; to produce an independent value use
// encode_session_init + decode_session_init. Cannot call .clone() on a SessionInit
// to feed both build_first_message_aad and encode_session_init — use references.

pub fn encode_session_init(si: &SessionInit) -> Result<Vec<u8>>
// Err(InvalidLength) if crypto_version exceeds u16::MAX bytes — the `expected` field in the error
//   struct means "maximum allowed" (65535), not "exact required size." This is the only place in
//   the API where InvalidLength.expected has this meaning.
// Err(InvalidData) on OPK co-presence violation (ct_opk and opk_id must both be Some or both None).

pub fn decode_session_init(data: &[u8]) -> Result<SessionInit>
// Err(InvalidData) on any structural parse failure: truncated fields, wrong field sizes,
// has_opk byte not 0x00/0x01, OPK co-presence violation, or trailing bytes.
// Err(UnsupportedCryptoVersion) if the decoded crypto_version string doesn't match "lo-crypto-v1".
// Wire format is opaque — always use encode_session_init/decode_session_init.
// Do not manually serialize SessionInit fields; field order and encoding are not part of the public API.

pub fn build_first_message_aad(
    sender_fingerprint: &[u8; 32],
    recipient_fingerprint: &[u8; 32],
    si: &SessionInit,
) -> Result<Vec<u8>>

pub fn build_first_message_aad_from_encoded(
    sender_fingerprint: &[u8; 32],
    recipient_fingerprint: &[u8; 32],
    si_encoded: &[u8],
) -> Result<Vec<u8>>  // Err(InvalidData) if si_encoded is empty

// Responder side (Bob)
#[must_use = "session establishment result contains key material that must not be discarded"]
pub fn receive_session(
    bob_ik_pk: &IdentityPublicKey,
    bob_ik_sk: &IdentitySecretKey,
    alice_ik_pk: &IdentityPublicKey,
    si: &SessionInit,
    sender_sig: &HybridSignature,
    spk_sk: &xwing::SecretKey,
    opk_sk: Option<&xwing::SecretKey>,
) -> Result<ReceivedSession>
// Err(UnsupportedCryptoVersion) — si.crypto_version not recognized.
// Err(InvalidData) — sender/recipient fingerprint mismatch, or OPK co-presence violation.
//   Two co-presence checks: (a) ct_opk and opk_id in si must both be Some or both None
//   (checked by encode_session_init before signature verification); (b) caller-supplied opk_sk
//   must be Some iff si.ct_opk is Some (checked after signature verification). Both collapse to
//   InvalidData — structural input validation failures, not KEM or signature verification outcomes.
// Err(VerificationFailed) — hybrid signature (Ed25519 + ML-DSA-65) over si did not verify.
// Err(DecapsulationFailed) — X-Wing KEM decapsulation failed for IK, SPK, or OPK ciphertext.
//   Structurally unreachable in the current implementation (ML-KEM uses implicit rejection);
//   retained as a defensive path.

// App-layer obligation — SessionInit deduplication: receive_session does not track or
// deduplicate incoming session inits. If a relay re-delivers the same SessionInit, the
// application will create a duplicate ratchet state for the same peer. The application
// MUST deduplicate SessionInit payloads (e.g., by hashing the encoded wire bytes or
// using a transport-level message ID) before calling receive_session.

// pub fields (ReceivedSession implements ZeroizeOnDrop — no partial moves):
//   pub peer_ek: xwing::PublicKey   — Clone, or .as_bytes().to_vec() → PublicKey::from_bytes()
impl ReceivedSession {
    pub fn take_root_key(&mut self) -> Zeroizing<[u8; 32]>         // destructive — second call returns zeros (init_bob rejects as InvalidData)
    pub fn take_initial_chain_key(&mut self) -> Zeroizing<[u8; 32]> // destructive — second call returns zeros (decrypt_first_message will fail AeadFailed)
}
```

### C API

```c
int32_t soliton_kex_sign_prekey(const uint8_t *ik_sk, uintptr_t ik_sk_len,
                                  const uint8_t *spk_pub, uintptr_t spk_pub_len,
                                  struct SolitonBuf *sig_out);

int32_t soliton_kex_verify_bundle(const uint8_t *bundle_ik_pk, uintptr_t bundle_ik_pk_len,
                                    const uint8_t *known_ik_pk, uintptr_t known_ik_pk_len,
                                    const uint8_t *spk_pub, uintptr_t spk_pub_len,
                                    const uint8_t *spk_sig, uintptr_t spk_sig_len,
                                    const char *crypto_version);
// Errors: NULL_POINTER (-13) — any parameter null; INVALID_LENGTH (-1) — wrong size for
//         bundle_ik_pk/known_ik_pk (LO_PUBLIC_KEY_SIZE), spk_pub (1216), or spk_sig (HYBRID_SIGNATURE_SIZE);
//         BUNDLE_VERIFICATION_FAILED (-5) — IK mismatch, invalid SPK signature, or unrecognized
//           crypto_version (all failures collapsed to prevent iterative-probe oracle).
// OPK parameters are absent — OPK co-presence validation is NOT performed here (opk_pub/opk_id
// are always treated as absent). Co-presence validation occurs inside soliton_kex_initiate /
// soliton_kex_receive.
// Note: soliton_kex_initiate performs the same IK + SPK + crypto_version checks internally —
// a prior call to soliton_kex_verify_bundle before soliton_kex_initiate is redundant. (Contrast:
// Rust callers must call verify_bundle because initiate_session requires a &VerifiedBundle.)

int32_t soliton_kex_initiate(const uint8_t *alice_ik_pk, uintptr_t alice_ik_pk_len,
                               const uint8_t *alice_ik_sk, uintptr_t alice_ik_sk_len,
                               const uint8_t *bob_ik_pk, uintptr_t bob_ik_pk_len,
                               const uint8_t *bob_spk_pub, uintptr_t bob_spk_pub_len,
                               uint32_t bob_spk_id,
                               const uint8_t *bob_spk_sig, uintptr_t bob_spk_sig_len,
                               const uint8_t *bob_opk_pub, uintptr_t bob_opk_pub_len,
                               // ^^^ Pass NULL / 0 for bob_opk_pub / bob_opk_pub_len and 0 for bob_opk_id when no OPK is available.
                               uint32_t bob_opk_id,
                               const char *crypto_version,
                               struct SolitonInitiatedSession *out);
// Errors: NULL_POINTER (-13) — any required pointer null.
//         INVALID_LENGTH (-1) — wrong size for any key or signature parameter (alice_ik_pk: 3200,
//           alice_ik_sk: 2496, bob_ik_pk: 3200, bob_spk_pub: 1216, bob_spk_sig: 3373,
//           bob_opk_pub: 1216 if non-null).
//         BUNDLE_VERIFICATION_FAILED (-5) — SPK sig invalid or unrecognized crypto_version string
//           (all failures collapsed via verify_bundle — same behavior as soliton_kex_verify_bundle).

void soliton_kex_initiated_session_free(struct SolitonInitiatedSession *session);

// Result of session initiation (Alice's side, §5.4).
// Inline keys (root_key, initial_chain_key, fingerprints) are zeroized, and all SolitonBuf fields
// (session_init_encoded, ek_pk, ek_sk, ct_ik, ct_spk, ct_opk, sender_sig) are freed by the free
// function — no manual soliton_buf_free calls on individual fields are needed.
// WARNING: struct assignment / memcpy creates bitwise copies with unzeroized key material.
// Pass by pointer and free as soon as keys have been extracted.
struct SolitonInitiatedSession {
    struct SolitonBuf session_init_encoded;        // encoded session init (caller frees)
    uint8_t root_key[32];                          // inline; zeroized by free
    uint8_t initial_chain_key[32];                 // inline; zeroized by free
    struct SolitonBuf ek_pk;                       // Alice's ephemeral X-Wing pk (1216 B)
    struct SolitonBuf ek_sk;                       // Alice's ephemeral X-Wing sk (2432 B); free via session free only
    uint8_t sender_ik_fingerprint[32];             // SHA3-256(Alice.IK_pub)
    uint8_t recipient_ik_fingerprint[32];          // SHA3-256(Bob.IK_pub)
    struct SolitonBuf ct_ik;                       // X-Wing ct to Bob's IK (1120 B)
    struct SolitonBuf ct_spk;                      // X-Wing ct to Bob's SPK (1120 B)
    uint32_t spk_id;
    struct SolitonBuf ct_opk;                      // conditional; null if no OPK
    uint32_t opk_id;
    uint8_t has_opk;                               // 0 = no OPK, 1 = OPK present
    struct SolitonBuf sender_sig;                  // hybrid signature (3373 B)
};

// Wire fields from Alice's SessionInit (populate from deserialized message)
struct SolitonSessionInitWire {
    const uint8_t *sender_sig;     uintptr_t sender_sig_len;     // 3373 B
    const uint8_t *sender_ik_fingerprint;  uintptr_t sender_ik_fingerprint_len;  // 32 B
    const uint8_t *recipient_ik_fingerprint; uintptr_t recipient_ik_fingerprint_len; // 32 B
    const uint8_t *sender_ek;      uintptr_t sender_ek_len;      // 1216 B
    const uint8_t *ct_ik;          uintptr_t ct_ik_len;          // 1120 B
    const uint8_t *ct_spk;         uintptr_t ct_spk_len;         // 1120 B
    uint32_t spk_id;
    const uint8_t *ct_opk;         uintptr_t ct_opk_len;         // null if no OPK
    uint32_t opk_id;
    const char *crypto_version;                                   // null-terminated
};

// Bob's pre-key secret keys (fetched from key store by spk_id/opk_id)
struct SolitonSessionDecapKeys {
    const uint8_t *spk_sk;  uintptr_t spk_sk_len;   // 2432 B
    const uint8_t *opk_sk;  uintptr_t opk_sk_len;   // null if no OPK
};

// Result: root_key + chain_key (inline, zeroized by free) + peer_ek (library-allocated)
// chain_key corresponds to Rust's take_initial_chain_key() — same value, different name.
struct SolitonReceivedSession {
    uint8_t root_key[32];
    uint8_t chain_key[32];
    struct SolitonBuf peer_ek;   // 1216 B; freed by soliton_kex_received_session_free
};
// GC pinning: root_key and chain_key are inline secret material. In GC'd runtimes (C#, Go, Python),
// the GC may copy the containing struct during compaction, leaving unzeroized copies of key material.
// Mitigation: use stack allocation only, extract and zeroize as quickly as possible, avoid storing
// in managed heap objects. soliton_kex_received_session_free zeroizes root_key and chain_key — call
// it promptly after extracting keys.

int32_t soliton_kex_receive(const uint8_t *bob_ik_pk, uintptr_t bob_ik_pk_len,
                              const uint8_t *bob_ik_sk, uintptr_t bob_ik_sk_len,
                              const uint8_t *alice_ik_pk, uintptr_t alice_ik_pk_len,
                              const struct SolitonSessionInitWire *wire,
                              const struct SolitonSessionDecapKeys *decap_keys,
                              struct SolitonReceivedSession *out);
// Caller obligation — key-ID mapping: wire->spk_id and wire->opk_id are opaque 4-byte identifiers
// chosen by the sender. The caller must map these IDs to the correct secret keys in decap_keys
// using its own key store. This library performs no key-ID lookup or validation — if the wrong
// secret key is supplied, the KEX silently produces incorrect shared secrets and the first ratchet
// message will fail AEAD authentication. Handle unknown or expired key IDs at the application
// layer before calling this function.
// Errors: NULL_POINTER (-13) — any required pointer null (including wire/decap_keys struct fields).
//         INVALID_LENGTH (-1) — wrong size for bob_ik_pk/alice_ik_pk (3200), bob_ik_sk (2496),
//           or any wire field (sender_ik_fingerprint/recipient_ik_fingerprint 32,
//           sender_sig 3373, sender_ek 1216, ct_ik/ct_spk 1120, ct_opk 1120 if present).
//         CRYPTO_VERSION (-16) — unrecognized crypto_version string.
//         INVALID_DATA (-17) — sender/recipient fingerprint mismatch, or OPK co-presence
//           violation (ct_opk and opk_sk must both be present or both absent).
//         VERIFICATION_FAILED (-3) — hybrid signature (Ed25519 + ML-DSA-65) over the encoded
//           SessionInit did not verify.
//         DECAPSULATION (-2) — X-Wing decapsulation failed; structurally unreachable with
//           ML-KEM implicit rejection but retained as a defensive path.

int32_t soliton_kex_encode_session_init(const char *crypto_version,
                                          const uint8_t *sender_fp, uintptr_t sender_fp_len,           // 32 bytes
                                          const uint8_t *recipient_fp, uintptr_t recipient_fp_len,     // 32 bytes
                                          const uint8_t *sender_ek, uintptr_t sender_ek_len,
                                          const uint8_t *ct_ik, uintptr_t ct_ik_len,
                                          const uint8_t *ct_spk, uintptr_t ct_spk_len,
                                          uint32_t spk_id,
                                          const uint8_t *ct_opk, uintptr_t ct_opk_len,
                                          uint32_t opk_id,
                                          struct SolitonBuf *encoded_out);
// Errors: NULL_POINTER (-13) — encoded_out or any required pointer (crypto_version, sender_fp,
//           recipient_fp, sender_ek, ct_ik, ct_spk) null; ct_opk null with nonzero ct_opk_len.
//         INVALID_LENGTH (-1) — sender_fp/recipient_fp != 32, sender_ek != 1216,
//           ct_ik/ct_spk != 1120, or ct_opk non-null but != 1120.
//         INVALID_DATA (-17) — opk_id non-zero when ct_opk is null (co-presence violation).

int32_t soliton_kex_build_first_message_aad(const uint8_t *sender_fp, uintptr_t sender_fp_len,     // 32 bytes
                                              const uint8_t *recipient_fp, uintptr_t recipient_fp_len, // 32 bytes
                                              const uint8_t *session_init_encoded,
                                              uintptr_t session_init_encoded_len,  // 0 or > 8192 → INVALID_LENGTH (-1); CAPI-only DoS bound — ~1.75× the max encoded size of ~4669 bytes; Rust has no cap (returns InvalidData for empty)
                                              struct SolitonBuf *aad_out);

// Decoded session init (~4.6 KiB with inline arrays).
// NOTE: large stack footprint — Go goroutines and similarly stack-constrained
// runtimes may need stack size adjustment or heap allocation.
struct SolitonDecodedSessionInit {
    struct SolitonBuf crypto_version;              // UTF-8 string (library-allocated); null-terminated — len includes the null byte; cast .ptr to const char* for direct C-string use
    uint8_t sender_fp[32];                         // SHA3-256(Alice.IK_pub)
    uint8_t recipient_fp[32];                      // SHA3-256(Bob.IK_pub)
    uint8_t sender_ek[SOLITON_XWING_PK_SIZE];     // 1216 B, inline
    uint8_t ct_ik[SOLITON_XWING_CT_SIZE];         // 1120 B, inline
    uint8_t ct_spk[SOLITON_XWING_CT_SIZE];        // 1120 B, inline
    uint32_t spk_id;
    uint8_t has_opk;                               // 0 = no OPK, 1 = present
    uint8_t ct_opk[SOLITON_XWING_CT_SIZE];        // 1120 B, inline (valid only if has_opk)
    uint32_t opk_id;
};

int32_t soliton_kex_decode_session_init(const uint8_t *encoded, uintptr_t encoded_len,
                                          struct SolitonDecodedSessionInit *out);
// INVALID_LENGTH (-1) — encoded_len == 0 (CAPI-only early guard; Rust returns InvalidData for empty
//   input instead); also encoded_len > 64 KiB / 65536.
// INVALID_DATA (-17) — structural parse failure.
// CRYPTO_VERSION (-16) — unrecognized crypto_version string.

void soliton_decoded_session_init_free(struct SolitonDecodedSessionInit *out);
// Frees the heap-allocated crypto_version SolitonBuf (nulls its ptr/len). All other fields
// (sender_fp, recipient_fp, sender_ek, ct_ik, ct_spk, has_opk, ct_opk, spk_id, opk_id) are
// inline arrays — no further cleanup required. Safe to call on a null pointer (no-op).

void soliton_kex_received_session_free(struct SolitonReceivedSession *session);
```

**No bundle-construction helper in the CAPI**: assemble `SolitonSessionInitWire` fields manually from `soliton_kex_sign_prekey` output and stored pre-key material. The Rust API has the same design — `PreKeyBundle` is caller-constructed.

**`SolitonDecodedSessionInit` vs `SolitonSessionInitWire`**: `soliton_kex_decode_session_init` populates a `SolitonDecodedSessionInit` (read-only inspection of the wire bytes — crypto_version, fingerprints, IDs, ciphertexts). `sender_sig` is **absent** from `SolitonDecodedSessionInit` — the decode function does not extract the signature; keep the raw sender_sig bytes separately. `soliton_kex_receive` takes a `SolitonSessionInitWire`, which carries `sender_sig` as a raw pointer field alongside the other wire data. These are distinct structs; `SolitonDecodedSessionInit` does not feed directly into `soliton_kex_receive`.

### Protocol flow

1. **Bob** publishes: IK\_pub, SPK\_pub, SPK\_sig, OPK\_pub (optional)
2. **Alice**: `verify_bundle(bundle, known_bob_ik)` — validates IK, verifies SPK\_sig, checks crypto\_version
3. **Alice**: `initiate_session(alice_ik, alice_ik_sk, &verified_bundle)` → `InitiatedSession { session_init: SessionInit, ek_pk, sender_sig, opk_used, ... }` — `root_key` and `initial_chain_key` are private; extract via `session.take_root_key()` / `session.take_initial_chain_key()` (destructive: each zeroes its internal copy; a second call returns zeroed bytes)
4. **Alice**: `encode_session_init(&si)` → wire bytes; `build_first_message_aad(sender_fp, recipient_fp, &si)` → AAD
5. **Alice**: `encrypt_first_message(initial_chain_key, plaintext, aad)` → `(encrypted_payload, ratchet_init_key)`
6. **Alice → Bob**: encoded `SessionInit` + `sender_sig` (3373 B) + `encrypted_payload`
7. **Bob**: `receive_session(...)` → `ReceivedSession` — extract `take_root_key()` / `take_initial_chain_key()` (same destructive semantics as `InitiatedSession`; `peer_ek` is public)
8. **Bob**: `build_first_message_aad(sender_fp, recipient_fp, &si)` → AAD; `decrypt_first_message(initial_chain_key, encrypted_payload, aad)` → `(plaintext, ratchet_init_key)`
9. **Both** initialize LO-Ratchet:
   - Alice: `init_alice(root_key, ratchet_init_key, local_fp, remote_fp, ek_pk, ek_sk)`
   - Bob: `init_bob(root_key, ratchet_init_key, local_fp, remote_fp, peer_ek)`
   - **Note:** the `chain_key` argument to `init_*` is `ratchet_init_key` (from step 5/8), **not** the `initial_chain_key`/`chain_key` from the KEX output.

### End-to-end data flow

How the output of each function feeds into the next (all Rust; CAPI follows the same order):

```
generate_identity()
  → alice_ik_pk, alice_ik_sk

verify_bundle(bundle, &alice_known_bob_ik)
  → verified_bundle

initiate_session(&alice_ik_pk, &alice_ik_sk, &verified_bundle)
  → session { take_root_key(), take_initial_chain_key(), ek_pk, ek_sk, session_init, sender_sig, opk_used }

encode_session_init(&session.session_init)  →  si_bytes (transmit)
build_first_message_aad(&sender_fp, &recipient_fp, &session.session_init)  →  aad

RatchetState::encrypt_first_message(session.take_initial_chain_key(), plaintext, &aad)
  → (encrypted_payload, ratchet_init_key)          (transmit encrypted_payload)

// ── Bob receives si_bytes + sender_sig + encrypted_payload ──────────────────

decode_session_init(&si_bytes)  →  si
// si.ct_ik, si.ct_spk, si.ct_opk are already xwing::Ciphertext — decode_session_init
// constructs them. To build a Ciphertext from raw bytes received elsewhere (e.g.,
// from a custom pre-key store): xwing::Ciphertext::from_bytes(bytes.to_vec())?

receive_session(&bob_ik_pk, &bob_ik_sk, &alice_ik_pk, &si, &sender_sig, &spk_sk, opk_sk)
  → session { take_root_key(), take_initial_chain_key(), peer_ek }

build_first_message_aad(&sender_fp, &recipient_fp, &si)  →  aad

RatchetState::decrypt_first_message(session.take_initial_chain_key(), &encrypted_payload, &aad)
  → (plaintext, ratchet_init_key)

// ── Both initialize the ratchet (ratchet_init_key from encrypt/decrypt_first_message) ──

// Alice:
// Note: take_root_key() and ratchet_init_key are Zeroizing<[u8; 32]>; init_alice takes [u8; 32].
// Deref-copy is required: let root = *session.take_root_key(); let chain_key = *ratchet_init_key;
// WARNING: the deref creates a bare [u8; 32] that is NOT zeroized after the call (same hazard as auth
// tokens — see Auth Notes). The copy is brief and then owned by RatchetState (ZeroizeOnDrop covers it).
// ek_pk/ek_sk: InitiatedSession implements ZeroizeOnDrop — partial moves are forbidden by the compiler.
//   let ek_pk = session.ek_pk.clone();  // PublicKey: Clone — no roundtrip needed
//   let ek_sk = xwing::SecretKey::from_bytes(session.ek_sk().as_bytes().to_vec())?;
//   (ek_sk: SecretKey is !Clone — must go through bytes; .to_vec() owns the Vec,
//    ZeroizeOnDrop on the resulting SecretKey covers that allocation; no unzeroized copy remains)
RatchetState::init_alice(root, chain_key, local_fp, remote_fp, ek_pk, ek_sk)
  → ratchet

// Bob:
// peer_ek: let peer_ek = xwing::PublicKey::from_bytes(session.peer_ek.as_bytes().to_vec())?;
// Deref-copy (same hazard as Alice): let root = *session.take_root_key(); let chain_key = *ratchet_init_key;
RatchetState::init_bob(root, chain_key, local_fp, remote_fp, peer_ek)
  → ratchet

// ── Ongoing messages ─────────────────────────────────────────────────────────

ratchet.encrypt(plaintext)  →  EncryptedMessage { header, ciphertext }   (transmit header + ciphertext)
ratchet.decrypt(&header, &ciphertext)  →  Zeroizing<Vec<u8>>
```

### C API end-to-end flow

**KEX→Ratchet key handoff**: `soliton_ratchet_encrypt_first` takes `initial_chain_key` and outputs `ratchet_init_key` into a caller-allocated 32-byte buffer — `soliton_ratchet_init_alice` takes that output buffer as `chain_key`, NOT `initial_chain_key`. Using the wrong key produces no immediate error; the mismatch surfaces silently at decryption.

`soliton_kex_initiate` populates `session.session_init_encoded` automatically — do NOT call `soliton_kex_encode_session_init` on an initiated session. (`soliton_kex_encode_session_init` exists for manual session-init construction only.)

`ek_pk`, `ek_sk`, and `peer_ek` are `SolitonBuf` structs — pass `.ptr`/`.len`, not the struct directly.

```c
// ── Alice (initiator) ─────────────────────────────────────────────────────
// Prerequisites: bob_ik_pk (3200 B), bob_spk_pub (1216 B), bob_spk_id,
//               bob_spk_sig (3373 B), from Bob's pre-key bundle.
SolitonInitiatedSession session = {0};
uint8_t ratchet_init_key[32];

soliton_kex_initiate(
    alice_ik_pk, SOLITON_PUBLIC_KEY_SIZE,   // 3200
    alice_ik_sk, SOLITON_SECRET_KEY_SIZE,   // 2496
    bob_ik_pk,   SOLITON_PUBLIC_KEY_SIZE,
    bob_spk_pub, SOLITON_XWING_PK_SIZE,     // 1216
    bob_spk_id,
    bob_spk_sig, SOLITON_HYBRID_SIG_SIZE,   // 3373
    NULL, 0, 0,                              // no OPK: opk_pub=NULL, len=0, id=0
    "lo-crypto-v1",
    &session);
// session.session_init_encoded is ready; session.root_key and session.initial_chain_key are inline

SolitonBuf aad = {0}, payload = {0};
soliton_kex_build_first_message_aad(
    session.sender_ik_fingerprint, 32,
    session.recipient_ik_fingerprint, 32,
    session.session_init_encoded.ptr, session.session_init_encoded.len,
    &aad);

// ↓ chain_key = session.initial_chain_key; ratchet_init_key receives the OUTPUT
soliton_ratchet_encrypt_first(
    session.initial_chain_key, 32,
    plaintext, plaintext_len,
    aad.ptr, aad.len,
    &payload, ratchet_init_key, 32);

SolitonRatchet *alice_ratchet = NULL;
// ↓ chain_key = ratchet_init_key (from encrypt_first) — NOT session.initial_chain_key
soliton_ratchet_init_alice(
    session.root_key, 32,
    ratchet_init_key, 32,
    session.sender_ik_fingerprint, 32,
    session.recipient_ik_fingerprint, 32,
    session.ek_pk.ptr, session.ek_pk.len,   // SolitonBuf → .ptr/.len
    session.ek_sk.ptr, session.ek_sk.len,
    &alice_ratchet);
soliton_zeroize(ratchet_init_key, 32);
// Transmit: session.session_init_encoded + session.sender_sig + payload
// (zeroize and free session via soliton_kex_initiated_session_free when done)

// ── Bob (responder) ───────────────────────────────────────────────────────
// Bob receives: si_encoded_bytes + sender_sig + payload from Alice.
// Populate wire struct (holds raw pointers into received/stored buffers):
SolitonSessionInitWire wire = {
    .sender_sig               = received_sig,      .sender_sig_len               = 3373,
    .sender_ik_fingerprint    = alice_fp,           .sender_ik_fingerprint_len    = 32,
    .recipient_ik_fingerprint = bob_fp,             .recipient_ik_fingerprint_len = 32,
    .sender_ek                = received_ek,        .sender_ek_len                = 1216,
    .ct_ik                    = received_ct_ik,     .ct_ik_len                    = 1120,
    .ct_spk                   = received_ct_spk,    .ct_spk_len                   = 1120,
    .spk_id                   = received_spk_id,
    .ct_opk = NULL, .ct_opk_len = 0, .opk_id = 0,  // no OPK
    .crypto_version           = "lo-crypto-v1",
};
// Bob looks up spk_sk from his key store using wire.spk_id:
SolitonSessionDecapKeys decap = {
    .spk_sk = bob_spk_sk, .spk_sk_len = 2432,  // SOLITON_XWING_SK_SIZE
    .opk_sk = NULL, .opk_sk_len = 0,
};
SolitonReceivedSession bob_session = {0};
soliton_kex_receive(
    bob_ik_pk, SOLITON_PUBLIC_KEY_SIZE,
    bob_ik_sk, SOLITON_SECRET_KEY_SIZE,
    alice_ik_pk, SOLITON_PUBLIC_KEY_SIZE,
    &wire, &decap, &bob_session);
// bob_session.root_key and bob_session.chain_key are inline

SolitonBuf bob_aad = {0}, plaintext_out = {0};
soliton_kex_build_first_message_aad(
    alice_fp, 32, bob_fp, 32,
    received_si_encoded, received_si_encoded_len,
    &bob_aad);

// ↓ chain_key = bob_session.chain_key; ratchet_init_key receives the OUTPUT
soliton_ratchet_decrypt_first(
    bob_session.chain_key, 32,
    payload_bytes, payload_len,
    bob_aad.ptr, bob_aad.len,
    &plaintext_out, ratchet_init_key, 32);

SolitonRatchet *bob_ratchet = NULL;
// ↓ chain_key = ratchet_init_key (from decrypt_first) — NOT bob_session.chain_key
soliton_ratchet_init_bob(
    bob_session.root_key, 32,
    ratchet_init_key, 32,
    bob_fp, 32, alice_fp, 32,
    bob_session.peer_ek.ptr, bob_session.peer_ek.len,  // SolitonBuf → .ptr/.len
    &bob_ratchet);
soliton_zeroize(ratchet_init_key, 32);
// (zeroize and free bob_session via soliton_kex_received_session_free when done)

// ── Ongoing encrypt/decrypt ───────────────────────────────────────────────
// msg is a value type — soliton_ratchet_encrypt takes SolitonEncryptedMessage *out (not **out).
// Declare as a value so that &msg has type SolitonEncryptedMessage *.
SolitonEncryptedMessage msg = {0};
soliton_ratchet_encrypt(alice_ratchet, plaintext, plaintext_len, &msg);
// Transmit: msg.header.{ratchet_pk,kem_ct,n,pn} + msg.ciphertext.ptr (msg.ciphertext.len bytes)

SolitonBuf decrypted = {0};
// soliton_ratchet_decrypt takes individual header fields, not SolitonRatchetHeader *.
soliton_ratchet_decrypt(bob_ratchet,
    msg.header.ratchet_pk.ptr, msg.header.ratchet_pk.len,  // ratchet_pk
    msg.header.kem_ct.ptr,     msg.header.kem_ct.len,      // kem_ct: NULL+0 when no ratchet step — pass .ptr/.len directly; NULL is correct and expected
    msg.header.n, msg.header.pn,                           // n, pn
    msg.ciphertext.ptr,        msg.ciphertext.len, &decrypted);
// decrypted.ptr contains plaintext — free with soliton_buf_free (zeroizes on free)

// ── Call keys (per-call ephemeral forward secrecy) ────────────────────────────
// kem_ss: from ephemeral KEM exchange in call signaling; call_id: random per-call.
uint8_t kem_ss[32], call_id[16];
soliton_random_bytes(kem_ss, 32);
soliton_random_bytes(call_id, 16);
SolitonCallKeys *call_keys = NULL;
soliton_ratchet_derive_call_keys(alice_ratchet, kem_ss, 32, call_id, 16, &call_keys);
uint8_t send_key[32];
soliton_call_keys_send_key(call_keys, send_key, 32);  // use for media encryption
soliton_call_keys_free(&call_keys);
soliton_zeroize(kem_ss, 32);
soliton_zeroize(send_key, 32);

// ── Storage (community blob round-trip) ───────────────────────────────────────
uint8_t storage_key[32];
soliton_random_bytes(storage_key, 32);
SolitonKeyRing *ring = NULL;
soliton_keyring_new(storage_key, 32, /*version=*/1, &ring);  // version 0 is rejected
soliton_zeroize(storage_key, 32);
SolitonBuf store_blob = {0};
soliton_storage_encrypt(ring, (const uint8_t *)"secret", 6, "channel-1", "seg-1",
                         /*compress=*/0, &store_blob);
SolitonBuf store_plain = {0};
soliton_storage_decrypt(ring, store_blob.ptr, store_blob.len,
                         "channel-1", "seg-1", &store_plain);
// store_plain.ptr contains plaintext — soliton_buf_free zeroizes on free
soliton_buf_free(&store_blob);
soliton_buf_free(&store_plain);
soliton_keyring_free(&ring);
```

### Notes

- SPK should rotate every ~7 days; retain old SPK private keys for ~30 days (delayed session init grace period) — advisory, not enforced by the library
- OPK is consumed once — delete its private key immediately after `receive_session` completes — advisory, not enforced by the library
- **OPK trust model**: `verify_bundle` checks the IK match, crypto_version, and the SPK signature. OPK public keys carry **no server-provided signature** — they are trusted as delivered by the server. There is no cryptographic proof that an OPK came from Bob; the server is trusted to serve genuine OPKs.
- `ct_ik` encapsulation provides authentication: only Bob's IK private key can decapsulate
- **HKDF info wire format** (for reimplementors): `b"lo-kex-v1" || u16_BE(len(crypto_version)) || crypto_version || u16_BE(len(alice_ik)) || alice_ik || u16_BE(len(bob_ik)) || bob_ik || u16_BE(len(ek)) || ek` — all length prefixes are big-endian u16. `alice_ik` and `bob_ik` are the full 3200-byte composite public keys; `ek` is Alice's 1216-byte ephemeral X-Wing public key. **`crypto_version` here is always the literal bytes `"lo-crypto-v1"` (the library constant) — not the value received from the peer.** Both `verify_bundle` and `decode_session_init` reject any other crypto_version before HKDF is computed, so the HKDF info is always built with the fixed 13-byte string.
- **KEX HKDF full call** (for reimplementors): `HKDF-SHA3-256(salt=HKDF_ZERO_SALT, ikm=ss_ik || ss_spk [|| ss_opk], info=<info above>, L=64)`. IKM is the concatenation of the 32-byte X-Wing shared secrets from the IK, SPK, and optional OPK encapsulations — 64 bytes without OPK, 96 bytes with OPK. Output split: bytes 0–31 = `root_key`, bytes 32–63 = `initial_chain_key`.
- **SPK signing wire format** (for reimplementors): `sign_prekey` signs `b"lo-spk-sig-v1" || spk_pub.as_bytes()` — the 14-byte label concatenated with the raw 1216-byte X-Wing public key. `verify_bundle` verifies the same construction.
- **`sender_sig` wire format** (for reimplementors): `initiate_session` signs `b"lo-kex-init-sig-v1" || encode_session_init(session_init)` — the 18-byte label concatenated with the binary-encoded `SessionInit`. `receive_session` verifies the same construction before any KEM operations.

## Ratchet — LO-Ratchet

KEM-based double ratchet providing post-quantum forward secrecy and break-in recovery for message sessions. `RatchetState` is mutable — every encrypt/decrypt call advances internal keys.

**Two init paths:**
- **Alice** (initiator): has `ek_pk` + `ek_sk` from `initiate_session` → call `init_alice`. First `encrypt()` does **not** perform a KEM step — send keys are immediately active.
- **Bob** (responder): has `peer_ek` from `receive_session` → call `init_bob`. First `encrypt()` **always** performs a KEM step (X-Wing keygen + encapsulate) — budget for the keygen overhead on Bob's first outgoing message.

**First message:** Use `encrypt_first_message` / `decrypt_first_message` for the session-init payload (before the ratchet is running). `encrypt_first_message` takes `initial_chain_key` and returns it **unchanged** as `ratchet_init_key` — counter-mode derivation uses the epoch key as a static KDF input (not a chain that advances), so the key itself is not consumed by encryption. **`ratchet_init_key` IS `initial_chain_key` — the same bytes, returned as-is.** Pass the returned value as the `epoch_key` parameter to `init_alice`/`init_bob` (the Rust parameter is named `epoch_key`; the CAPI parameter is named `chain_key`). Do not call `take_initial_chain_key()` a second time (it would return zeros, which `init_alice`/`init_bob` reject).

### State and sizes

| Type | Description | Notes |
|------|-------------|-------|
| `RatchetState` | Opaque mutable session state | All key material; zeroized on drop (manual `Drop` calling `reset()` — does NOT implement `Zeroize` or `ZeroizeOnDrop`; `Zeroizing<RatchetState>` won't compile) |
| root\_key | Current root key | 32 bytes; advanced on each KEM ratchet step |
| send/recv epoch\_key | Epoch keys | 32 bytes each; static within epoch, replaced on KEM ratchet step |
| send\_ratchet\_pk | Sender's X-Wing public key | 1216 bytes; `Option<_>` — None for Bob until first send; included in every outgoing header once set |
| send\_ratchet\_sk | Sender's X-Wing secret key | 2432 bytes; `Option<_>` — None for Bob until first send |
| recv\_ratchet\_pk | Peer's X-Wing public key | 1216 bytes; updated on KEM ratchet step |

### EncryptedMessage fields

| Field | Size | Notes |
|-------|------|-------|
| `header.ratchet_pk` | 1216 B | Sender's current X-Wing public key (cleartext) |
| `header.kem_ct` | 1120 B or absent | KEM ciphertext (present only on ratchet step; cleartext) |
| `header.n` | 4 B | Message counter within current send chain (cleartext) |
| `header.pn` | 4 B | Previous chain length (cleartext; non-zero on ratchet step) |
| `ciphertext` | variable | XChaCha20-Poly1305 ciphertext + 16 B tag |

**Header transport framing is application-defined.** The library provides raw field access on `RatchetHeader` but no public `encode_ratchet_header` / `decode_ratchet_header` functions. The application defines how to serialize and transmit the four header fields (`ratchet_pk`, optional `kem_ct`, `n`, `pn`) over the wire — both sides must use the same framing. **The AEAD authentication data (AAD) uses a separate internal encoding** that is spec-defined and deterministic — callers never supply it, but reimplementors need the full byte layout (see security notes).

**Recommended minimal framing** (not mandated; both ends must use the same scheme):
```
ratchet_pk      1216 B  (fixed — sender's current X-Wing public key)
has_kem_ct         1 B  (0x01 = KEM ciphertext present, 0x00 = absent)
kem_ct          1120 B  (present only when has_kem_ct == 0x01)
n                  4 B  (big-endian u32 — message counter)
pn                 4 B  (big-endian u32 — previous chain length)
ciphertext_len     4 B  (big-endian u32 — byte length of ciphertext field)
ciphertext      variable
```
This layout mirrors the internal AAD encoding exactly (see security notes), so the same serialization logic can serve both transport framing and AAD construction in reimplementations. `ciphertext_len` is redundant if the transport already frames message boundaries, but including it makes single-pass parsing unambiguous.

Callers do not supply AAD to `encrypt`/`decrypt` — it is derived internally from the fingerprints bound at init and the message header. For first messages, use `build_first_message_aad` (Rust) or `soliton_kex_build_first_message_aad` (CAPI).

### Rust API

```rust
pub struct RatchetHeader {
    pub ratchet_pk: xwing::PublicKey,   // sender's current X-Wing public key (1216 B, always present)
    pub kem_ct: Option<xwing::Ciphertext>, // KEM ciphertext (present only on ratchet step, 1120 B)
    pub n: u32,                          // message counter within current send epoch
    pub pn: u32,                         // length of previous send epoch (0 if no ratchet step yet)
}

pub struct EncryptedMessage {
    pub header: RatchetHeader,           // sent in cleartext
    pub ciphertext: Vec<u8>,             // XChaCha20-Poly1305 ciphertext + 16-byte Poly1305 tag
}

// Serializing a RatchetHeader for transport (sender side):
let pk_bytes: &[u8] = msg.header.ratchet_pk.as_bytes();            // always 1216 bytes
let has_ct: bool = msg.header.kem_ct.is_some();
let ct_bytes: Option<&[u8]> = msg.header.kem_ct.as_ref().map(|ct| ct.as_bytes()); // Some(1120 bytes) or None
let n: u32 = msg.header.n;
let pn: u32 = msg.header.pn;
// ciphertext bytes for wire: msg.ciphertext.as_slice()

// Reconstructing a RatchetHeader from wire bytes (receiver side):
let header = RatchetHeader {
    ratchet_pk: xwing::PublicKey::from_bytes(pk_bytes.to_vec())?,
    kem_ct: if has_ct {
        Some(xwing::Ciphertext::from_bytes(ct_bytes_wire.to_vec())?)
    } else {
        None
    },
    n,
    pn,
};
let plaintext = ratchet.decrypt(&header, &ciphertext_wire)?;

// All of the following are associated functions on RatchetState — call as RatchetState::init_alice(...),
// RatchetState::encrypt_first_message(...), etc. None are free module-level functions.
// encrypt_first_message and decrypt_first_message are STATIC (no self receiver) — call as
//   RatchetState::encrypt_first_message(key, plaintext, aad), not ratchet.encrypt_first_message(...).
impl RatchetState {

// Initialization (once per session, after KEX — fingerprints bound at init time)
pub fn init_alice(root_key: [u8; 32], epoch_key: [u8; 32],
                  local_fp: [u8; 32], remote_fp: [u8; 32],
                  ek_pk: xwing::PublicKey, ek_sk: xwing::SecretKey) -> Result<RatchetState>
// Returns InvalidData if root_key or epoch_key is all-zeros, local_fp == remote_fp,
// or either fingerprint is all-zeros (i.e. derived from an all-zero public key).
// Security: root_key and epoch_key are [u8; 32] (Copy) — the caller's stack copies survive the call.
// Zeroize them explicitly after init_alice returns (e.g. root_key.zeroize()).

pub fn init_bob(root_key: [u8; 32], epoch_key: [u8; 32],
                local_fp: [u8; 32], remote_fp: [u8; 32],
                peer_ek: xwing::PublicKey) -> Result<RatchetState>
// Same InvalidData conditions and Copy zeroization obligation as init_alice.

// Ongoing encryption (mutates state; fingerprints stored in state, not per-call)
#[must_use = "dropping the result desynchronizes the ratchet — send_count advanced but message never sent"]
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<EncryptedMessage>
// Zero-length plaintext is valid — &[] produces a 16-byte ciphertext (Poly1305 tag only).
// Err(InvalidData) — ratchet state was previously zeroized by reset() or a prior AeadFailed;
//   requires new KEX. The all-zero root_key check fires at entry (constant-time).
// Err(ChainExhausted) — send_count at u32::MAX (permanent; requires new KEX)
// Err(AeadFailed) — AEAD failed (session-fatal; state zeroized; all subsequent encrypt/decrypt
//   calls return InvalidData). Drop the handle or call reset(); to_bytes() is callable but
//   produces a zombie blob (zeroed keys) that is rejected by from_bytes/from_bytes_with_min_epoch
//   with InvalidData — the all-zero root_key check fires at deserialization.

#[must_use = "decrypted plaintext contains sensitive data that must be consumed or zeroized"]
pub fn decrypt(&mut self, header: &RatchetHeader, ciphertext: &[u8]) -> Result<Zeroizing<Vec<u8>>>
// Err(DuplicateMessage) — counter already in recv_seen (state unchanged)
// Err(ChainExhausted) — counter at u32::MAX, or recv_seen / prev_recv_seen set reached 65536 entries
// Err(InvalidData) — ratchet state previously zeroized by reset() or a prior AeadFailed (requires new KEX);
//   or ratchet step required but no KEM ct in header; or no send secret key
// Err(DecapsulationFailed) — X-Wing KEM decapsulation failed
// Err(AeadFailed) — AEAD authentication failed (state rolled back)
// Note: ciphertext len 0 and 1–15 bytes have no minimum-length gate in the Rust API —
//   all short inputs reach AEAD and return AeadFailed (not InvalidLength).
//   Contrast: CAPI soliton_ratchet_decrypt returns INVALID_LENGTH for ciphertext_len == 0;
//   1–15 bytes pass the CAPI length gate and also return AEAD.

// First message (before ratchet is running)
#[must_use = "result contains the epoch key needed for ratchet initialization"]
pub fn encrypt_first_message(epoch_key: Zeroizing<[u8; 32]>, plaintext: &[u8], aad: &[u8])
    -> Result<(Vec<u8>, Zeroizing<[u8; 32]>)>
// Returns (random 24-B nonce || ciphertext || 16-B Poly1305 tag, ratchet_init_key)
// Output length = len(plaintext) + 40 bytes. Minimum output for empty plaintext: 40 bytes (24 nonce + 0 plaintext + 16 tag).
// ratchet_init_key IS epoch_key — the input key is returned unchanged; pass it directly to init_alice/init_bob as the epoch_key argument.
// The 24-byte nonce is random, NOT counter-derived — distinct from ratchet message nonces ([0x00 × 20] || n.to_be_bytes()).
// Counter 0 is consumed by this call (kdf_msg_key(epoch_key, 0)). init_alice/init_bob therefore
// start their send/recv counters at 1, not 0. Never use counter 0 in the ratchet symmetric epoch.
// Err(AeadFailed) — structurally infallible for XChaCha20-Poly1305 (defense-in-depth only)

#[must_use = "result contains the epoch key needed for ratchet initialization"]
pub fn decrypt_first_message(epoch_key: Zeroizing<[u8; 32]>, encrypted_payload: &[u8], aad: &[u8])
    -> Result<(Zeroizing<Vec<u8>>, Zeroizing<[u8; 32]>)>
// Returns (plaintext, ratchet_init_key) — ratchet_init_key IS epoch_key, returned unchanged.
// Err(AeadFailed) — wrong key, tampered ciphertext, wrong AAD, or payload too short (< 40 bytes)

// State management
pub fn to_bytes(self) -> Result<(Zeroizing<Vec<u8>>, u64)>  // consumes self; returns (blob, epoch)
// Wire format is opaque and subject to change — treat the blob as a black box; use to_bytes/from_bytes exclusively. Cross-implementation compatibility is not guaranteed.
// WARNING: takes ownership (moves self). On Err, the ratchet is DROPPED (zeroized) — unlike the CAPI,
// there is no recovery path. Call can_serialize() first; if false, do NOT call to_bytes().
// Err(ChainExhausted) — send_count, recv_count, prev_send_count == u32::MAX, or epoch == u64::MAX
// Err(InvalidData) — recv_seen or prev_recv_seen has ≥ 65536 entries (defense-in-depth dead branch:
//   decrypt's ChainExhausted guard fires at recv_seen.len() == 65536, preventing the set from growing
//   further, so this InvalidData path is unreachable in normal operation; the global error table lists
//   recv_seen exhaustion under ChainExhausted for this reason)
#[deprecated(note = "Use from_bytes_with_min_epoch for anti-rollback protection")]
pub fn from_bytes(data: &[u8]) -> Result<Self>  // no rollback protection — always use from_bytes_with_min_epoch
// Err(InvalidData) — structural parse failure, zero/equal fingerprints, all-zero root_key
//   (session was zeroized by reset() or AEAD failure — the zombie-blob guard), or invalid field values.
// Err(UnsupportedVersion) — blob version byte ≠ RATCHET_BLOB_VERSION.
// Err(ChainExhausted) — stored epoch == u64::MAX (defensive; a correct to_bytes never writes this, but from_bytes guards against crafted blobs).
pub fn from_bytes_with_min_epoch(data: &[u8], min_epoch: u64) -> Result<Self>  // always use this; requires blob_epoch > min_epoch (strict); pass (epoch - 1) to load the blob that returned epoch
// Same errors as from_bytes, plus Err(InvalidData) when epoch ≤ min_epoch (rollback rejection).
pub fn epoch(&self) -> u64               // epoch stored in the next to_bytes() blob = epoch() + 1; see serialize/resume workflow
pub fn reset(&mut self)                  // zeroizes all key material in-place
// WARNING: to_bytes() after reset() succeeds but produces a zombie blob — a blob with all-zero
// root_key that is rejected by from_bytes/from_bytes_with_min_epoch with InvalidData. Do not
// persist a reset ratchet as if it were a live session.
pub fn can_serialize(&self) -> bool      // false when send_count, recv_count, or prev_send_count == u32::MAX, epoch == u64::MAX, or a recv_seen/prev_recv_seen set has ≥ 65536 entries; reset() zeros counters to 0, not u32::MAX — does not trigger this check; safe to call after reset
pub fn derive_call_keys(&self, kem_ss: &[u8; 32], call_id: &[u8; 16]) -> Result<CallKeys>  // see Call Keys section
} // end impl RatchetState
```

### C API

```c
int32_t soliton_ratchet_init_alice(const uint8_t *root_key, uintptr_t root_key_len,
                                   const uint8_t *chain_key, uintptr_t chain_key_len,
                                   const uint8_t *local_fp, uintptr_t local_fp_len,
                                   const uint8_t *remote_fp, uintptr_t remote_fp_len,
                                   const uint8_t *ek_pk, uintptr_t ek_pk_len,
                                   const uint8_t *ek_sk, uintptr_t ek_sk_len,
                                   struct SolitonRatchet **out);
// Errors: NULL_POINTER (-13); INVALID_LENGTH (-1) — wrong size for any field (root_key/chain_key/fp: 32,
//         ek_pk: 1216, ek_sk: 2432); INVALID_DATA (-17) — all-zero root/chain key, equal fingerprints, or all-zero fingerprint.
// Security: root_key and chain_key are copied into ratchet state — zeroize your copies immediately after this call.

int32_t soliton_ratchet_init_bob(const uint8_t *root_key, uintptr_t root_key_len,
                                 const uint8_t *chain_key, uintptr_t chain_key_len,
                                 const uint8_t *local_fp, uintptr_t local_fp_len,
                                 const uint8_t *remote_fp, uintptr_t remote_fp_len,
                                 const uint8_t *peer_ek, uintptr_t peer_ek_len,
                                 struct SolitonRatchet **out);
// Same errors as soliton_ratchet_init_alice (peer_ek: 1216; no ek_sk parameter).
// Security: same zeroization obligation for root_key and chain_key.

// Ratchet header (cleartext metadata accompanying each encrypted message)
struct SolitonRatchetHeader {
    struct SolitonBuf ratchet_pk;   // sender's X-Wing pk (1216 B; library-allocated)
    struct SolitonBuf kem_ct;       // KEM ct (1120 B; null if no ratchet step)
    uint32_t n;                     // message counter within current send chain
    uint32_t pn;                    // previous chain length
};

// Encrypted message returned from ratchet encrypt
struct SolitonEncryptedMessage {
    struct SolitonRatchetHeader header;
    struct SolitonBuf ciphertext;   // library-allocated
};

// Fingerprints bound at init time — not passed per-call
int32_t soliton_ratchet_encrypt(struct SolitonRatchet *ratchet,
                                const uint8_t *plaintext, uintptr_t plaintext_len,
                                struct SolitonEncryptedMessage *out);
// Zero-length plaintext is valid — pass NULL for plaintext with plaintext_len == 0; produces 16-byte ciphertext (Poly1305 tag only).
// Errors: INVALID_DATA (-17) — ratchet was previously zeroized by reset or a prior AEAD failure (requires new KEX);
//         CHAIN_EXHAUSTED (-15) — send_count at u32::MAX;
//         AEAD (-4) — AEAD failed (session-fatal: state zeroized, all further calls return INVALID_DATA — call soliton_ratchet_free, not reset)

int32_t soliton_ratchet_decrypt(struct SolitonRatchet *ratchet,
                                const uint8_t *ratchet_pk, uintptr_t ratchet_pk_len,
                                const uint8_t *kem_ct, uintptr_t kem_ct_len, // null if absent
                                uint32_t n, uint32_t pn,
                                const uint8_t *ciphertext, uintptr_t ciphertext_len,
                                  struct SolitonBuf *plaintext_out);
// Errors: DUPLICATE (-7); CHAIN_EXHAUSTED (-15) — counter at u32::MAX, or recv_seen / prev_recv_seen set reached 65536 entries;
//   INVALID_LENGTH (-1) — ratchet_pk_len != 1216, kem_ct_len != 1120 when kem_ct non-null, ciphertext_len == 0, or > 256 MiB (see global cap note);
//   INVALID_DATA (-17) — bad header (structurally invalid ratchet state);
//   NULL_POINTER (-13) — kem_ct null with nonzero kem_ct_len, or vice versa (co-presence violation);
//   DECAPSULATION (-2); AEAD (-4, state rolled back).
//   Note: ciphertexts of 1–15 bytes are NOT caught by INVALID_LENGTH — they pass the length gate and return AEAD (-4).

int32_t soliton_ratchet_encrypt_first(const uint8_t *chain_key, uintptr_t chain_key_len,
                                     const uint8_t *plaintext, uintptr_t plaintext_len,
                                     const uint8_t *aad, uintptr_t aad_len,
                                     struct SolitonBuf *payload_out,
                                     uint8_t *ratchet_init_key_out,
                                     uintptr_t ratchet_init_key_out_len); // 32 bytes
// Errors: NULL_POINTER (-13) — chain_key, payload_out, or ratchet_init_key_out null; or plaintext null
//         with plaintext_len > 0; or aad null with aad_len > 0. NULL plaintext/aad with len == 0 is valid.
//         INVALID_LENGTH (-1) — chain_key != 32 or ratchet_init_key_out_len != 32;
//         AEAD (-4) — structurally infallible for XChaCha20 (defense-in-depth only).

int32_t soliton_ratchet_decrypt_first(const uint8_t *chain_key, uintptr_t chain_key_len,
                                      const uint8_t *encrypted_payload, uintptr_t encrypted_payload_len,
                                      const uint8_t *aad, uintptr_t aad_len,
                                        struct SolitonBuf *plaintext_out,
                                        uint8_t *ratchet_init_key_out,
                                        uintptr_t ratchet_init_key_out_len); // 32 bytes
// Errors: NULL_POINTER (-13) — chain_key, encrypted_payload, plaintext_out, or ratchet_init_key_out null;
//         or aad null with aad_len > 0. NULL aad with aad_len == 0 is valid.
//         INVALID_LENGTH (-1) — chain_key != 32, ratchet_init_key_out_len != 32, or encrypted_payload_len == 0;
//         AEAD (-4) — wrong key, tampered ciphertext, wrong AAD, or payload < 40 bytes (1–39 bytes → AeadFailed, not INVALID_LENGTH).

int32_t soliton_ratchet_to_bytes(struct SolitonRatchet **ratchet,
                                 struct SolitonBuf *data_out,
                                 uint64_t *epoch_out);  // consumes ratchet; epoch_out may be NULL (but passing NULL discards the epoch, disabling anti-rollback protection — always pass a valid pointer)
// Errors: NULL_POINTER (-13); INVALID_DATA (-17) — magic check failure (wrong handle type passed);
//         CONCURRENT_ACCESS (-18); CHAIN_EXHAUSTED (-15) — see below.
// Handle consumption: *ratchet is nulled unconditionally once Box::from_raw succeeds (after the
// can_serialize() gate), regardless of whether to_bytes() itself fails — the handle is irrecoverably
// consumed. NULL_POINTER, INVALID_DATA, CONCURRENT_ACCESS, and CHAIN_EXHAUSTED return before
// Box::from_raw and do NOT null *ratchet.

int32_t soliton_ratchet_from_bytes(const uint8_t *data, uintptr_t data_len,
                                    struct SolitonRatchet **out);
// ^^^ avoid — no rollback protection. Use soliton_ratchet_from_bytes_with_min_epoch.
// Returns INVALID_LENGTH (-1) if data_len == 0 or data_len > 1 MiB (1,048,576 bytes) — tighter than the 256 MiB cap on other functions.
// Returns INVALID_DATA (-17) on structural parse failure, zero/equal fingerprints, or invalid field values.
// Returns UNSUPPORTED_VERSION (-10) if blob version byte ≠ RATCHET_BLOB_VERSION.
// Returns CHAIN_EXHAUSTED (-15) if the stored epoch is u64::MAX (defensive guard; correct to_bytes never writes this).

int32_t soliton_ratchet_from_bytes_with_min_epoch(const uint8_t *data, uintptr_t data_len,
                                                    uint64_t min_epoch,
                                                    struct SolitonRatchet **out);
// ^^^ always prefer this; pass (epoch_out - 1), NOT epoch_out — blob contains epoch_out, min_epoch must be strictly less.
// Same 1 MiB cap and same error codes as soliton_ratchet_from_bytes.
// Rollback rejection (epoch ≤ min_epoch) returns INVALID_DATA (-17).

int32_t soliton_ratchet_epoch(const struct SolitonRatchet *ratchet,
                               uint64_t *epoch_out);
// Returns the current epoch E (same semantics as Rust epoch()). soliton_ratchet_to_bytes stores E+1
// in the blob and returns epoch_out = E+1. DO NOT pass epoch_out directly as min_epoch — that rejects
// the blob you just wrote. Use min_epoch = epoch_out - 1 (the Quick Rule in the serialize workflow).

int32_t soliton_ratchet_reset(struct SolitonRatchet *ratchet);
// WARNING: a reset ratchet serializes successfully but contains zeroed key material.
// soliton_ratchet_reset() followed immediately by soliton_ratchet_to_bytes() produces
// a valid-looking blob that is rejected by soliton_ratchet_from_bytes with
// SOLITON_ERR_INVALID_DATA — the all-zero root_key check fires at deserialization.
// Do not store a reset ratchet as if it were a live session.
int32_t soliton_ratchet_free(struct SolitonRatchet **ratchet);
void    soliton_encrypted_message_free(struct SolitonEncryptedMessage *msg);
// No soliton_ratchet_can_serialize — the Rust can_serialize() check is implicit in
// soliton_ratchet_to_bytes (returns CHAIN_EXHAUSTED on all can_serialize() failures).
// See "CAPI serialization guard" in the Notes section for details.
```

### Notes

- **Decrypt with rollback**: on any error after state mutation, the full state snapshot (root key, epoch keys, ratchet keys, counters, recv\_seen set) is atomically restored — the session is not corrupted on a bad message. `DuplicateMessage` and recv\_seen `ChainExhausted` are detected **after** AEAD (deferring avoids a timing oracle: both duplicate and non-duplicate messages run full AEAD before the check). On the CurrentEpoch and PreviousEpoch paths where these errors arise, no state mutations occur before AEAD, so the rollback restores identical state (no-op). `ChainExhausted` from counter overflow (`header.n == u32::MAX`) and `InvalidData` from a missing KEM ciphertext are returned before any mutations and before AEAD.
- **KEM ratchet step key derivation** (for reimplementors): on a KEM ratchet step (direction change), new root and epoch keys are derived as `HKDF-SHA3-256(salt=old_root_key, ikm=xwing_shared_secret, info=b"lo-ratchet-v1", L=64)` → first 32 bytes = `new_root_key`, last 32 bytes = `new_epoch_key`. The old root key is zeroized after use as the HKDF salt. The new epoch key becomes the symmetric chain key for that direction.
- **Counter-mode key derivation**: message keys are derived as `HMAC-SHA3-256(epoch_key, 0x01 || counter_BE)` where `counter_BE` is `n.to_be_bytes()` — a 4-byte big-endian u32. The full HMAC input is 5 bytes (0x01 || [4 bytes]). O(1) for any message position. No sequential chain advancement. Forward secrecy is per-epoch (per KEM ratchet step), not per-message.
- **Out-of-order support**: any message counter within the current epoch is directly derivable. Duplicate detection uses a `recv_seen` set (HashSet\<u32\>, capped at 65536 entries — the runtime guard is `len >= 65536` before insert, so the 65536th distinct-counter decrypt succeeds (growing the set to 65536) and the 65537th distinct-counter decrypt returns `ChainExhausted`; `can_serialize()` returns false once the set reaches 65536). A seen counter returns `DuplicateMessage`. Previous-epoch messages are handled via a one-epoch grace period (`prev_recv_epoch_key`); a separate `prev_recv_seen` set tracks them (also returns `DuplicateMessage` for seen counters), subject to the same 65536-entry cap. Messages from two or more KEM ratchet epochs ago are permanently unrecoverable — the key was zeroized on the second ratchet step; `decrypt()` returns `AeadFailed`.
- **Each side initialises its active counter to 1, not 0**: `init_alice` sets `send_count = 1` (Alice's send path is immediately active); `init_bob` sets `recv_count = 1` (Bob's receive path is immediately active). The inactive counter on each side starts at 0. Counter 0 is reserved because `encrypt_first_message` internally calls `kdf_msg_key(epoch_key, 0)` — the HMAC input for that key is `0x01 || 0x00000000` (5 bytes; counter_BE is the 4-byte big-endian representation of 0u32: `[0x00, 0x00, 0x00, 0x00]`, consistent with `MSG_KEY_DOMAIN_BYTE = 0x01`). That counter slot is consumed by the session-init payload and must never be reused by the ratchet. Alice's first outgoing ratchet message has `header.n = 1`; Bob's first accepted message must also have `n ≥ 1`.
- **Nonce reuse guard**: send/receive counters abort before reaching u32::MAX (would cause AEAD nonce reuse on the same epoch key).
- **Length leakage**: XChaCha20-Poly1305 is a stream cipher — ciphertext length equals plaintext length plus the 16-byte tag. A passive observer can determine exact message length. Application-layer padding is required if message length must be hidden.
- **Message nonce format**: `[0x00 × 20] || n.to_be_bytes()` — counter `n` occupies the last 4 bytes of the 24-byte XChaCha20 nonce; bytes 0–19 are zero. Each nonce is used with a unique per-message key (`kdf_msg_key`), so nonce distinctness only needs to hold within a single key's use.
- **Header in AAD — full layout**: AAD is `b"lo-dm-v1" || sender_fp (32 B) || recipient_fp (32 B) || encode_ratchet_header(header)`, where `encode_ratchet_header` produces:
  ```
  ratchet_pk (1216 B, fixed — no length prefix)
  has_kem_ct (1 B: 0x00 or 0x01)
  [if 0x01: len(kem_ct) (2 B big-endian u16) || kem_ct (1120 B)]
  n (4 B big-endian u32)
  pn (4 B big-endian u32)
  ```
  This binds sender/recipient identity, the current ratchet public key, the optional KEM ciphertext, and the message counters to the ciphertext — preventing cross-session replay and header-swap attacks.
- **KEM ratchet on direction change**: receiver performs KEM decapsulation with its own send-side secret key to derive fresh root + receive chain keys — post-compromise security.
- **Bob's first encrypt() is always a KEM ratchet step**: `init_bob` sets `ratchet_pending = true` and `send_epoch_key = [0u8; 32]` (a deliberate placeholder — never used for key derivation). Bob's first `encrypt()` call always performs a full X-Wing keygen + encapsulate, overwriting `send_epoch_key` before any message key is derived. This means Bob's first outgoing message always carries a KEM ciphertext in the header regardless of prior `decrypt()` calls. The `test-utils` accessor `send_epoch_key_ptr()` on a freshly initialized Bob returns a pointer to zeroed bytes. By contrast, **Alice starts with `ratchet_pending = false`** — her send keys are immediately active and her first `encrypt()` does not perform a KEM step (no keygen overhead).
- Serialized state contains all secret material — encrypt before storing (e.g., with `storage::encrypt_blob`).
- **`RatchetState: Send + Sync`** (auto-derived — all fields are `Send + Sync`). Safe to transfer across threads or store in `Arc<Mutex<RatchetState>>`. All mutating operations require `&mut self`, so `Arc<RwLock<RatchetState>>` only permits concurrent reads — use `Arc<Mutex<RatchetState>>` for any setup that includes encrypt or decrypt.
- **`RatchetState: !Clone`** — contains `xwing::SecretKey` which is `!Clone`. To checkpoint and restore session state, use `to_bytes`/`from_bytes`; there is no clone shortcut.

### Serialize and resume workflow

`to_bytes` **consumes** the ratchet handle on success — it cannot be used after serialization. The returned epoch must be persisted alongside the blob for anti-rollback protection on reload.

**Exception — `SOLITON_ERR_CHAIN_EXHAUSTED`**: if `soliton_ratchet_to_bytes` returns this error, `*ratchet` is **NOT** consumed — the handle remains valid and the session is still usable. Send or receive additional messages to advance the ratchet counters, then retry serialization. Retrying `soliton_ratchet_to_bytes` without advancing the ratchet is safe: the `can_serialize()` pre-check fires again, returns `SOLITON_ERR_CHAIN_EXHAUSTED`, and the handle is not consumed.

For **`send_count` overflow** (send_count at u32::MAX): **not recoverable** — `encrypt()` checks `send_count == u32::MAX` before performing any KEM ratchet step, so even if the peer sends a new ratchet key (`ratchet_pending=true`), the ratchet step is never reached and `send_count` is never reset. Requires a new KEX. For **`recv_count` or `prev_send_count` overflow** (recv_count/prev_send_count at u32::MAX): **recoverable** — the peer sending a KEM ratchet step (NewEpoch decrypt) resets `recv_count = 0`; a subsequent local `encrypt()` that performs a KEM ratchet step resets `prev_send_count` to the current (lower) `send_count`. For **recv_seen exhaustion** (recv_seen/prev_recv_seen ≥ 65536 entries — also returns `CHAIN_EXHAUSTED` via the CAPI's `can_serialize()` pre-check): **recoverable** — the peer sending a KEM ratchet step (NewEpoch decrypt) resets the recv_seen set. Sending more messages from the local side does not clear recv_seen.

**Quick rule**: `min_epoch = saved_epoch - 1`. The blob stores `epoch = saved_epoch`; `from_bytes_with_min_epoch` requires `blob_epoch > min_epoch` (strict greater-than). Passing `saved_epoch` itself would reject the blob; passing `saved_epoch - 2` would accept one stale epoch. `saved_epoch` is always ≥ 1 (to_bytes writes `internal_epoch + 1`; a fresh ratchet has internal epoch 0, so the first serialized blob has epoch 1) — the subtraction never underflows for u64 CAPI callers.

**Initial save**: a fresh ratchet has `epoch() == 0`; the first `to_bytes` stores `epoch = 1` (i.e., `epoch() + 1`). Load it with `min_epoch = 0` — there is no "previous save", so the floor is 0.

```
// Save:
(blob, epoch) = soliton_ratchet_to_bytes(&ratchet, &data_out, &epoch_out)
// On success: ratchet is now NULL — do NOT use it
// On CHAIN_EXHAUSTED: ratchet is still valid — continue sending/receiving, then retry
encrypt_and_store(data_out, epoch)

// Resume:
(data, saved_epoch) = load_and_decrypt()
// Pass saved_epoch - 1: blob contains epoch = saved_epoch, and min_epoch must be strictly less.
// This accepts the current blob and rejects any older blob (whose epoch < saved_epoch).
ratchet = soliton_ratchet_from_bytes_with_min_epoch(data, saved_epoch - 1)
// Continue encrypt/decrypt with the new handle
```

In the Rust API, `to_bytes(self)` takes ownership (moves). In the C API, `soliton_ratchet_to_bytes` nulls the `SolitonRatchet **` pointer.

Rust save/load cycle (first save uses `min_epoch = 0`; subsequent saves use `saved_epoch - 1`):

```rust
// Save:
if ratchet.can_serialize() {
    let (blob, epoch) = ratchet.to_bytes()?;  // consumes ratchet — do not use after this
    // For a fresh ratchet: epoch == 1; min_epoch for reload = 0 (= epoch - 1).
    persist_encrypted(blob, epoch);            // store both; epoch is needed for anti-rollback
} else {
    // ChainExhausted: send/receive more messages to advance counters, then retry.
    // (to_bytes would also return ChainExhausted and also NOT consume the ratchet.)
}

// Reload:
let (blob, saved_epoch) = load_and_decrypt()?;
// min_epoch = saved_epoch - 1 (strict: blob epoch must be > min_epoch).
// For the very first save (epoch == 1): min_epoch = 0.
let ratchet = RatchetState::from_bytes_with_min_epoch(&blob, saved_epoch - 1)?;
```

**Per-session scoping**: `min_epoch` MUST be stored and compared per session (keyed on the `local_fp, remote_fp` pair). A single global `min_epoch` across all sessions allows cross-session replay: an attacker can substitute a blob from session A (higher epoch) into session B (lower epoch), which passes the stale floor check.

**CAPI serialization guard**: there is no `soliton_ratchet_can_serialize`. The Rust `can_serialize()` guard is implicit — `soliton_ratchet_to_bytes` returns `SOLITON_ERR_CHAIN_EXHAUSTED` (-15) for all `can_serialize()` failures: `send_count`, `recv_count`, or `prev_send_count` equals `u32::MAX`; `epoch` equals `u64::MAX`; or a `recv_seen`/`prev_recv_seen` set has ≥ 65536 entries. (The CAPI does not distinguish between these cases.) Note: a `soliton_ratchet_reset()` ratchet is NOT blocked by this guard — reset zeros counters to 0, not u32::MAX, so the overflow check does not fire. A reset ratchet serializes successfully but contains zeroed key material.

---

## Call Keys

Derives per-call media encryption keys from the ratchet state and an ephemeral KEM exchange. Provides forward secrecy independent of the message ratchet.

### Rust API

```rust
// Derived from RatchetState (preferred — fingerprints read from state)
pub fn derive_call_keys(
    &self,           // RatchetState (uses stored fingerprints for role assignment)
    kem_ss: &[u8; 32],    // ephemeral X-Wing shared secret from call signaling
    call_id: &[u8; 16],   // random per-call identifier
) -> Result<CallKeys>

// A module-level soliton::call::derive_call_keys variant exists with the full signature:
//   pub fn derive_call_keys(root_key: &[u8;32], kem_ss: &[u8;32], call_id: &[u8;16],
//                           local_fp: &[u8;32], remote_fp: &[u8;32]) -> Result<CallKeys>
// root_key is private to RatchetState and only accessible via the test-utils feature
// (root_key_bytes()); production callers always use the RatchetState method above.

impl CallKeys {
    pub fn send_key(&self) -> &[u8; 32]
    pub fn recv_key(&self) -> &[u8; 32]
    pub fn advance(&mut self) -> Result<()>  // ratchets both keys forward; returns ChainExhausted after 2^24 (16,777,216) calls
    // After ChainExhausted: send_key, recv_key, and chain_key are all zeroized —
    // subsequent send_key()/recv_key() return &[0u8; 32]. Establish a new call.
    // The internal step_count caps at MAX_CALL_ADVANCE (2^24) and does not increment further —
    // every subsequent advance() call also returns ChainExhausted (and re-zeroizes, which is a no-op).
}
```

### C API

```c
int32_t soliton_ratchet_derive_call_keys(const struct SolitonRatchet *ratchet,
                                           const uint8_t *kem_ss, uintptr_t kem_ss_len,
                                           const uint8_t *call_id, uintptr_t call_id_len,
                                           struct SolitonCallKeys **out);
// Errors: NULL_POINTER (-13) — any pointer null; INVALID_DATA (-17) — magic check failed,
//   ratchet is dead (all-zero root key), kem_ss all-zero, or call_id all-zero;
//   INVALID_LENGTH (-1) — kem_ss_len != 32 or call_id_len != 16;
//   CONCURRENT_ACCESS (-18) — ratchet handle in use.

int32_t soliton_call_keys_send_key(const struct SolitonCallKeys *keys,
                                     uint8_t *out, uintptr_t out_len);  // out_len must be 32
// Errors: NULL_POINTER (-13); INVALID_LENGTH (-1) — out_len != 32;
//   INVALID_DATA (-17) — wrong handle type; CONCURRENT_ACCESS (-18).

int32_t soliton_call_keys_recv_key(const struct SolitonCallKeys *keys,
                                     uint8_t *out, uintptr_t out_len);  // out_len must be 32
// Same errors as soliton_call_keys_send_key.

int32_t soliton_call_keys_advance(struct SolitonCallKeys *keys);
int32_t soliton_call_keys_free(struct SolitonCallKeys **keys);
```

**No CAPI equivalent for the module-level `call::derive_call_keys`**: the CAPI exposes only `soliton_ratchet_derive_call_keys` (requires a live `SolitonRatchet *` handle). The Rust module-level variant takes `root_key: &[u8;32]` directly, but `root_key` is private in `RatchetState` and only accessible via the `test-utils` feature (`root_key_bytes()`). Production callers always use the `RatchetState` method; the module-level variant has no production use case. CAPI callers must hold a live ratchet handle or re-deserialize before deriving call keys.

### Call signaling protocol

1. **Caller** generates an X-Wing keypair: `soliton_xwing_keygen()` → `(call_pk, call_sk)`
2. **Caller → Callee** (over the existing ratchet channel): send `call_pk` + a random `call_id` (16 bytes). Generate with `random::random_array::<16>()` (Rust) or `soliton_random_bytes(call_id, 16)` (CAPI).
3. **Callee** encapsulates: `soliton_xwing_encapsulate(call_pk)` → `(ct, kem_ss)`
4. **Callee → Caller**: send `ct` (1120 bytes)
5. **Caller** decapsulates: `soliton_xwing_decapsulate(call_sk, ct)` → `kem_ss`
6. **Both**: `derive_call_keys(ratchet, kem_ss, call_id)` → `CallKeys { send_key, recv_key }`
7. **Both**: use `send_key`/`recv_key` for media encryption; call `advance()` periodically to rotate keys

### Notes

- **Wire format (for reimplementors)**:
  ```
  # derive_call_keys:
  HKDF(salt=root_key, ikm=kem_ss ‖ call_id, info="lo-call-v1" ‖ fp_lo ‖ fp_hi, L=96)
    → key_a (32) | key_b (32) | chain_key (32)
  # fp_lo / fp_hi: fingerprints sorted lexicographically (lower first); both parties use the same order.
  # ikm is 48 bytes (kem_ss 32 + call_id 16); unambiguous — no length prefix needed.

  # advance() step:
  key_a'     = HMAC-SHA3-256(chain_key, 0x04)
  key_b'     = HMAC-SHA3-256(chain_key, 0x05)
  chain_key' = HMAC-SHA3-256(chain_key, 0x06)
  # Old chain_key and old keys are zeroized after each step.
  ```
- Role assignment (send vs. recv key) is determined by lexicographic order of fingerprints — consistent across both parties and across `advance()` calls.
- `advance()` ratchets both keys forward and zeroizes the previous values. Returns `ChainExhausted` after 2^24 (16,777,216) calls; on exhaustion, keys are zeroized — the caller must establish a new call with a fresh KEM exchange.
- Both parties must call `derive_call_keys` before either side triggers a KEM ratchet step (direction change). The root key changes only on a KEM ratchet step, not on every message — regular sends/receives within the same epoch do not change the root key. If either side triggers a ratchet step between call-offer and call-answer, the root keys diverge and call keys won't match (manifesting as AEAD failure on the first media packet).
- `derive_call_keys` returns `InvalidData` if: ratchet `root_key` is all-zeros (post-`reset()` or after encrypt error), `kem_ss` is all-zeros (uninitialized buffer), `call_id` is all-zeros (uninitialized buffer), or `local_fp == remote_fp` (equal fingerprints collapse send/recv role separation).
- **`CallKeys: Send + Sync`** (auto-derived). `advance` requires `&mut self` — use `Arc<Mutex<CallKeys>>` for shared mutable access. The CAPI layer adds a runtime reentrancy guard for FFI callers.

---

## Storage — Encrypted Keyring Storage

Multi-version XChaCha20-Poly1305 keyring for encrypted blob storage with optional zstd compression. Two AAD variants: community storage (§11.4.1, keyed by channel + segment) and DM queue (§11.4.2, keyed by recipient fingerprint + batch).

| Item | Size | Notes |
|------|------|-------|
| Key (per version) | 32 bytes | XChaCha20-Poly1305 key |
| Nonce | 24 bytes | Random per blob; stored in blob header |
| Auth tag | 16 bytes | Appended to ciphertext |
| Key version | 1 byte | 1-255; 0 is reserved and rejected |
| Flags byte | 1 byte | Bit 0 = compression; bits 1-7 reserved |
| Minimum blob length | 42 bytes | version(1) + flags(1) + nonce(24) + tag(16) |
| channel\_id / segment\_id | Variable UTF-8 | Max 65535 bytes each; bound into AAD (community) |
| recipient\_fp | 32 bytes | Identity fingerprint; bound into AAD (DM queue) |
| batch\_id | Variable UTF-8 | Max 65535 bytes; bound into AAD (DM queue) |

### Rust API

```rust
// StorageKey — required argument to StorageKeyRing::new and encrypt functions.
// Fields are private (enforces version ≠ 0 invariant). Construct with StorageKey::new().
// StorageKey derives Clone, Zeroize, and ZeroizeOnDrop — key material is automatically zeroized on drop.
// Clone freely if you need the same key in multiple keyrings; each clone is independently zeroized.
impl StorageKey {
    pub fn new(version: u8, key: [u8; 32]) -> Result<Self>
    // UnsupportedVersion if version == 0; InvalidData if key is all-zeros (constant-time check — key is secret material).
    // [u8; 32] is Copy — new() zeroizes its own parameter copy on all paths (success and error).
    // The caller's source variable is NOT zeroized; call key.zeroize() at the call site after new().
    pub fn version(&self) -> u8
    pub fn key(&self) -> &[u8; 32]
}

impl StorageKeyRing {
    pub fn new(key: StorageKey) -> Result<Self>  // always returns Ok (never Err) — returns Result<Self> for API consistency with add_key; the key passed to new() becomes the initial active key
    pub fn add_key(&mut self, key: StorageKey, make_active: bool) -> Result<bool>
    // Ok(true) = version already existed (replaced, including when replacing the current active key with make_active=true).
    // Ok(false) = new version inserted.
    // InvalidData if version == active_version and make_active == false (active key material replacement requires re-activation).
    pub fn remove_key(&mut self, version: u8) -> Result<bool>
    // UnsupportedVersion if version == 0. InvalidData if version == active_version (set a new active key first).
    // Ok(true) = key removed; Ok(false) = version not found.
    pub fn active_key(&self) -> Option<&StorageKey>
    pub fn get_key(&self, version: u8) -> Option<&StorageKey>
}
// Persistence: StorageKeyRing has no built-in serialization. Persist key bytes and version
// numbers externally; reconstruct on startup with StorageKeyRing::new + add_key.
// Reconstruction order matters: add all historical keys with make_active=false first,
// then add (or re-add) the current active key with make_active=true. Adding in the wrong
// order or omitting make_active=true leaves the wrong key active for new encryptions.
//
// Concrete startup reconstruction (two stored key versions: v1 old, v2 current):
let mut ring = StorageKeyRing::new(StorageKey::new(1, old_key_bytes)?)?;
ring.add_key(StorageKey::new(2, current_key_bytes)?, true)?;   // make_active=true — sets active version to 2
// To read the key bytes back for re-persistence: ring.get_key(v).map(|k| k.key())
// To read the active version: ring.active_key().map(|k| k.version())

// Community storage (§11.4.1)
pub fn encrypt_blob(key: &StorageKey, plaintext: &[u8],
                    channel_id: &str, segment_id: &str,
                    compress: bool) -> Result<Vec<u8>>

pub fn decrypt_blob(keyring: &StorageKeyRing, blob: &[u8],
                    channel_id: &str, segment_id: &str) -> Result<Zeroizing<Vec<u8>>>

// DM queue storage (§11.4.2)
pub fn encrypt_dm_queue_blob(key: &StorageKey, plaintext: &[u8],
                             recipient_fp: &[u8; 32], batch_id: &str,
                             compress: bool) -> Result<Vec<u8>>

pub fn decrypt_dm_queue_blob(keyring: &StorageKeyRing, blob: &[u8],
                             recipient_fp: &[u8; 32],
                             batch_id: &str) -> Result<Zeroizing<Vec<u8>>>
```

### C API

```c
int32_t soliton_keyring_new(const uint8_t *key, uintptr_t key_len,
                              uint8_t version,
                              struct SolitonKeyRing **out);
// version == 0 → UnsupportedVersion (-10) — key version 0 is reserved. key all-zeros → INVALID_DATA (-17).

int32_t soliton_keyring_add_key(struct SolitonKeyRing *keyring,
                                  const uint8_t *key, uintptr_t key_len,
                                  uint8_t version,
                                  int32_t make_active);
// version == 0 → UnsupportedVersion (-10) — key version 0 is reserved.
// version == active_version AND make_active == 0 → INVALID_DATA (-17): replacing active key material without re-activating.
// Note: unlike Rust add_key (returns Ok(true) on replacement, Ok(false) on new insertion), the CAPI returns only 0/error — insertion vs. replacement is not distinguishable.

int32_t soliton_keyring_remove_key(struct SolitonKeyRing *keyring, uint8_t version);
// version == 0 → UnsupportedVersion (-10) — key version 0 is reserved.
// version == active_version → INVALID_DATA (-17): promote another key with make_active first.
// version not in keyring → 0 (success, no-op; the version was already absent).

int32_t soliton_keyring_free(struct SolitonKeyRing **keyring);

// Community storage (§11.4.1)
int32_t soliton_storage_encrypt(const struct SolitonKeyRing *keyring,
                                  const uint8_t *plaintext, uintptr_t plaintext_len,
                                  const char *channel_id, const char *segment_id,
                                  int32_t compress,
                                  struct SolitonBuf *blob_out);

int32_t soliton_storage_decrypt(const struct SolitonKeyRing *keyring,
                                  const uint8_t *blob, uintptr_t blob_len,
                                  const char *channel_id, const char *segment_id,
                                  struct SolitonBuf *plaintext_out);

// DM queue storage (§11.4.2)
int32_t soliton_dm_queue_encrypt(const struct SolitonKeyRing *keyring,
                                   const uint8_t *plaintext, uintptr_t plaintext_len,
                                   const uint8_t *recipient_fp, uintptr_t recipient_fp_len,
                                   const char *batch_id,
                                   int32_t compress,
                                   struct SolitonBuf *blob_out);

int32_t soliton_dm_queue_decrypt(const struct SolitonKeyRing *keyring,
                                   const uint8_t *blob, uintptr_t blob_len,
                                   const uint8_t *recipient_fp, uintptr_t recipient_fp_len,
                                   const char *batch_id,
                                   struct SolitonBuf *plaintext_out);
```

### Notes

- **Active key (CAPI)**: `soliton_storage_encrypt` / `soliton_dm_queue_encrypt` always use the keyring's **active** key (set by `soliton_keyring_new` or `soliton_keyring_add_key` with `make_active!=0`). Adding a key without `make_active` does **not** change which key encrypts. If no active key is present, both functions return `SOLITON_ERR_INVALID_DATA` (-17) — a defensive path; in practice the CAPI prevents this by rejecting removal of the active key (see below). **Rust**: `encrypt_blob` / `encrypt_dm_queue_blob` take a `key: &StorageKey` directly — the caller picks the key explicitly; there is no active-key concept on the Rust side. Decrypt functions (both APIs) use the version byte stored in the blob header to look up the correct key.
- **Key inspection (Rust only)**: `active_key() -> Option<&StorageKey>` and `get_key(version: u8) -> Option<&StorageKey>` have no CAPI equivalents — the active key is managed internally and applied automatically by the encrypt functions. C callers cannot inspect keyring contents after construction. The active key is always set: `soliton_keyring_remove_key` returns `INVALID_DATA` (-17) if asked to remove the active version, so a keyring handle always has a valid active key.
- **Key rotation (CAPI)**: to rotate to a new key: (1) generate a new `StorageKey`; (2) call `soliton_keyring_add_key(keyring, new_key, new_key_len, 1)` — `make_active=1` promotes it as the active encrypt key; (3) old keys remain available for decryption by their version byte in the blob header; (4) once all blobs encrypted under an old version have expired, call `soliton_keyring_remove_key(keyring, old_version)` to remove it. Do not remove a version while any live blobs reference it — decryption will fail with `SOLITON_ERR_AEAD`.
- **Community vs DM queue blobs are not interchangeable** — the AAD domain labels differ (`lo-storage-v1` vs `lo-dm-queue-v1`), so decrypting a community blob with the DM queue function (or vice versa) always fails.
- **Storage blob wire layout** (for reimplementors): `version (1 B) || flags (1 B) || nonce (24 B) || ciphertext + AEAD tag`. `version` is the `StorageKey.version` byte stored in the blob header (used by `decrypt_blob` to look up the key). `flags` bit 0 = zstd compression (plaintext compressed before AEAD if set; bits 1–7 reserved). Nonce is randomly generated per blob. Encryption: `XChaCha20-Poly1305(key=StorageKey.key, nonce=blob_nonce, plaintext=data, aad=<aad below>)`.
- **Storage AAD wire format** (for reimplementors):
  - Community: `b"lo-storage-v1" || version (1 B) || flags (1 B) || u16_BE(len(channel_id)) || channel_id || u16_BE(len(segment_id)) || segment_id`
  - DM queue: `b"lo-dm-queue-v1" || version (1 B) || flags (1 B) || u16_BE(len(recipient_fp)) || recipient_fp || u16_BE(len(batch_id)) || batch_id`
- **Exact-match AAD**: `channel_id`, `segment_id`, and `batch_id` are encoded as raw UTF-8 bytes with no normalization. Unicode NFC vs NFD, trailing whitespace, and case differences produce different AAD values and silent authentication failures. Callers must ensure these strings are byte-identical at encrypt and decrypt time.
- Blobs are not portable across key rings — key version and all AAD fields are bound into authentication.
- Compression flag is stored in the blob header — no caller-side tracking needed.
- **compress parameter type**: storage functions (`soliton_storage_encrypt`, `soliton_dm_queue_encrypt`) take `int32_t compress` (non-zero = enable); streaming functions (`soliton_stream_encrypt_init`) take `bool compress` (C99 `_Bool`). Passing `1` works for both, but the types differ — C++ and strict-mode C compilers may warn on a mismatch.
- **Compression and memory security**: when `compress=true` (CAPI) / `compress: true` (Rust), zstd internal buffers may retain plaintext in freed heap memory. Avoid compression when encrypting highly sensitive material (e.g., identity key blobs) where heap residue is a concern.
- Decompression is bounded to 256 MiB (native) / 16 MiB (wasm32) to prevent zip-bomb OOM attacks. Mixed-platform deployments should enforce the lower 16 MiB limit at the application layer.
- **Encrypt-side size cap (Rust)**: `encrypt_blob` and `encrypt_dm_queue_blob` return `Err(InvalidData)` if plaintext exceeds 256 MiB on native (16 MiB on wasm32) — matching the decrypt-side decompression cap so that a native-encrypted blob is always decryptable by a WASM client if the lower limit is respected.
- Decrypted plaintext is returned as `Zeroizing<Vec<u8>>` — automatically zeroized on drop.
- **`StorageKeyRing: Send + Sync`** (auto-derived). Mutating operations (`add_key`, `remove_key`) require `&mut self` — use `Arc<Mutex<StorageKeyRing>>` for shared mutable access across threads. The CAPI layer adds a runtime reentrancy guard for FFI callers; Rust callers must serialize access externally.
- **`StorageKeyRing: !Clone`** — the struct deliberately omits `#[derive(Clone)]` despite all its fields being Clone. To share a keyring between two services, pass `Arc<Mutex<StorageKeyRing>>`. `StorageKey` itself derives `Clone` — individual keys can be cloned for reconstruction.

---

## X-Wing — Post-Quantum KEM

ML-KEM-768 + X25519 hybrid KEM per [draft-connolly-cfrg-xwing-kem-09](https://datatracker.ietf.org/doc/draft-connolly-cfrg-xwing-kem/).

| Type | Size (bytes) |
|------|-------------|
| XWingPublicKey | 1216 (X25519 32 + ML-KEM-768 1184) |
| XWingSecretKey | 2432 (X25519 32 + ML-KEM-768 2400) |
| XWingCiphertext | 1120 (X25519 ephemeral 32 + ML-KEM-768 ct 1088) |
| XWingSharedSecret | 32 (SHA3-256 output) |
| Combiner label | 6 (`0x5c2e2f2f5e5c`) |

### Rust API

```rust
pub fn keygen() -> Result<(PublicKey, SecretKey)>
pub fn encapsulate(pk: &PublicKey) -> Result<(Ciphertext, SharedSecret)>
pub fn decapsulate(sk: &SecretKey, ct: &Ciphertext) -> Result<SharedSecret>

// PublicKey: Clone; not ZeroizeOnDrop (non-secret) — clone freely.
// SecretKey: !Clone + ZeroizeOnDrop — cannot clone; to create an owned copy:
//   SecretKey::from_bytes(sk.as_bytes().to_vec())  // takes ownership of the Vec; ZeroizeOnDrop covers it
impl PublicKey {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Size-only check: Err(InvalidLength) if len ≠ 1216. Sub-key structure (X25519, ML-KEM-768) validated lazily at encapsulate/decapsulate.
    pub fn x25519_pk(&self) -> &[u8]
    pub fn mlkem_pk(&self) -> &[u8]
}
impl SecretKey {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 2432; on Err, the input Vec is zeroized before returning (wrapped in Zeroizing internally)
}
impl Ciphertext {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len != 1120
}
impl SharedSecret {
    pub fn as_bytes(&self) -> &[u8; 32]
}
// SharedSecret: !Clone + ZeroizeOnDrop — extract bytes with as_bytes() if you need to copy them.
```

**Encoding order** (relevant only if splitting raw bytes from `as_bytes()` / `from_bytes()` manually):
- Public key: `X25519_pk (32) || ML-KEM-768_pk (1184)`
- Secret key: `X25519_sk (32) || ML-KEM-768_sk (2400)`
- Ciphertext: `X25519_ephemeral_pk (32) || ML-KEM-768_ct (1088)`

### C API

```c
int32_t soliton_xwing_keygen(struct SolitonBuf *pk_out, struct SolitonBuf *sk_out);
// NULL_POINTER (-13) if pk_out or sk_out is null.

int32_t soliton_xwing_encapsulate(const uint8_t *pk, uintptr_t pk_len,
                                    struct SolitonBuf *ct_out,
                                    uint8_t *ss_out, uintptr_t ss_out_len);  // ss_out: 32 bytes
// NULL_POINTER (-13) if ct_out, ss_out, or pk is null.
// INVALID_LENGTH (-1) if pk_len != 1216 or ss_out_len != 32.

int32_t soliton_xwing_decapsulate(const uint8_t *sk, uintptr_t sk_len,
                                    const uint8_t *ct, uintptr_t ct_len,
                                    uint8_t *ss_out, uintptr_t ss_out_len);  // ss_out: 32 bytes
// NULL_POINTER (-13) if ss_out, sk, or ct is null.
// INVALID_LENGTH (-1) if ss_out_len != 32, sk_len != 2432, or ct_len != 1120.
```

### Notes

- Safe to use as a standalone KEM — not only via the Identity layer.
- All intermediate secret material (ephemeral SK, DH outputs) is zeroized before returning.
- `xwing::PublicKey` implements `PartialEq` via `subtle::ConstantTimeEq` (constant-time) and derives `Eq`. Does **not** implement `Hash` — not directly usable in `HashSet`/`HashMap`.
- `xwing::Ciphertext` is `Clone` and derives standard `PartialEq + Eq` (variable-time). Ciphertexts are public, so timing risk is negligible. Does **not** implement `Hash` — not directly usable in `HashSet`/`HashMap`.

---

## Primitives — Low-Level Cryptographic Operations

Direct access to underlying primitives. Prefer higher-level APIs (Identity, X-Wing, Ratchet) where possible.

| Function | Algorithm | Output | Notes |
|----------|-----------|--------|-------|
| `random_bytes` | OS CSPRNG | fills buf | Panics if CSPRNG unavailable |
| `sha3_256::hash` | SHA3-256 | 32 bytes | Deterministic; full path `primitives::sha3_256::hash` |
| `sha3_256::fingerprint_hex` | SHA3-256 | hex String | SHA3-256 of any `&[u8]` as lowercase hex; not restricted to identity key sizes |
| `hmac_sha3_256` | HMAC-SHA3-256 | 32 bytes | Key any length |
| `hkdf_sha3_256` | HKDF-SHA3-256 (RFC 5869) | up to 8160 bytes | Extract-and-expand in one call |
| `aead_encrypt` | XChaCha20-Poly1305 | plaintext\_len + 16 bytes | Tag appended |
| `aead_decrypt` | XChaCha20-Poly1305 | plaintext (Zeroizing) | Err on tag mismatch |
| `argon2id` | Argon2id (RFC 9106) | caller-allocated | Password-based KDF for key protection |

### Rust API

All functions below returning key material, hashes, ciphertexts, or `Result` values carry `#[must_use]` — the compiler enforces this; annotate wrapper functions accordingly.

```rust
// Call sites use the module prefix from the import block above (e.g. random::random_bytes, sha3_256::hash).
// These are NOT free functions at crate root — all live in sub-modules of soliton::primitives.

// ── primitives::random ───────────────────────────────────────────────────────
pub fn random_bytes(buf: &mut [u8])
pub fn random_array<const N: usize>() -> [u8; N]
// Returns [u8; N] which is Copy — the callee's stack copy is not zeroized before return.
// When used for key material, wrap immediately: let key = Zeroizing::new(random::random_array::<32>());
// then zeroize the source if it was placed in a separate binding before wrapping.

// ── primitives::sha3_256 ─────────────────────────────────────────────────────
pub fn hash(data: &[u8]) -> [u8; 32]          // SHA3-256; call as sha3_256::hash(...)
pub fn fingerprint_hex(pk: &[u8]) -> String   // SHA3-256 → lowercase hex; accepts any-length &[u8], not restricted to identity key sizes

// ── primitives::hmac ─────────────────────────────────────────────────────────
pub fn hmac_sha3_256(key: &[u8], data: &[u8]) -> [u8; 32]  // call as hmac::hmac_sha3_256(...)
// Returns a plain [u8; 32] (Copy type). If used as secret key material, wrap immediately:
//   let tag = Zeroizing::new(hmac::hmac_sha3_256(key, data));
// Without wrapping, the [u8; 32] is not automatically zeroized on drop.
#[must_use]
pub fn hmac_sha3_256_verify_raw(a: &[u8; 32], b: &[u8; 32]) -> bool
// Constant-time equality of two pre-computed HMAC outputs — does NOT compute HMAC.
// Compute both tags with hmac_sha3_256() first, then compare with this function.

// ── primitives::hkdf ─────────────────────────────────────────────────────────
pub fn hkdf_sha3_256(salt: &[u8], ikm: &[u8], info: &[u8], out: &mut [u8]) -> Result<()>
// call as hkdf::hkdf_sha3_256(...); Err(InvalidLength) if out is empty or exceeds 8160 bytes.

// ── primitives::aead ─────────────────────────────────────────────────────────
pub fn aead_encrypt(key: &[u8; 32], nonce: &[u8; 24],
                         plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>>
// call as aead::aead_encrypt(...); structurally infallible for any plaintext within the 256 MiB cap —
// XChaCha20-Poly1305 encrypt returns Err only if plaintext + 16 overflows usize (unreachable) or the
// underlying cipher hits an internal length limit (also unreachable at normal sizes). Result is for
// defense-in-depth; callers may treat Err(AeadFailed) as a library bug.

pub fn aead_decrypt(key: &[u8; 32], nonce: &[u8; 24],
                         ciphertext: &[u8], aad: &[u8]) -> Result<Zeroizing<Vec<u8>>>
// call as aead::aead_decrypt(...)
```

### C API

```c
// Returns the library version string — the Cargo crate semver (e.g., "0.1.0"),
// not the spec revision. Static lifetime, null-terminated. Caller must NOT free.
// Rust equivalent: soliton::VERSION (&str, same value).
const char *soliton_version(void);

// Fills buf with len cryptographically secure random bytes (OS entropy).
// Max 256 MiB per call. Returns 0 on success. Zero len is a no-op returning 0.
int32_t soliton_random_bytes(uint8_t *buf, uintptr_t len);

int32_t soliton_sha3_256(const uint8_t *data, uintptr_t data_len,
                           uint8_t *out, uintptr_t out_len);  // out: 32 bytes

int32_t soliton_hmac_sha3_256(const uint8_t *key, uintptr_t key_len,
                                const uint8_t *data, uintptr_t data_len,
                                uint8_t *out, uintptr_t out_len);  // out: 32 bytes

int32_t soliton_hmac_sha3_256_verify(const uint8_t *tag_a, uintptr_t tag_a_len,
                                       const uint8_t *tag_b, uintptr_t tag_b_len);
// 0 = match, SOLITON_ERR_VERIFICATION (-3) = mismatch; constant-time.
// SOLITON_ERR_NULL_POINTER (-13) if either pointer is null.
// SOLITON_ERR_INVALID_LENGTH (-1) if tag_a_len != 32 or tag_b_len != 32.

int32_t soliton_hkdf_sha3_256(const uint8_t *salt, uintptr_t salt_len,
                                const uint8_t *ikm, uintptr_t ikm_len,
                                const uint8_t *info, uintptr_t info_len,
                                uint8_t *out, uintptr_t out_len);

int32_t soliton_aead_encrypt(const uint8_t *key, uintptr_t key_len,         // 32 bytes
                               const uint8_t *nonce, uintptr_t nonce_len,     // 24 bytes
                               const uint8_t *plaintext, uintptr_t plaintext_len,
                               const uint8_t *aad, uintptr_t aad_len,
                               struct SolitonBuf *ciphertext_out);

int32_t soliton_aead_decrypt(const uint8_t *key, uintptr_t key_len,           // 32 bytes
                               const uint8_t *nonce, uintptr_t nonce_len,       // 24 bytes
                               const uint8_t *ciphertext, uintptr_t ciphertext_len,
                               const uint8_t *aad, uintptr_t aad_len,
                               struct SolitonBuf *plaintext_out);

// Volatile-write zeroize (see also C API conventions section).
// Rust callers: use .zeroize() from the zeroize crate on keys/buffers directly.
void soliton_zeroize(uint8_t *ptr, uintptr_t len);
```

### AEAD details

| Property | Value |
|----------|-------|
| Algorithm | XChaCha20-Poly1305 |
| Key | 32 bytes |
| Nonce | 24 bytes |
| Auth tag | 16 bytes (appended to ciphertext) |
| Ciphertext length | plaintext\_len + 16 |
| Constant-time | By construction (ARX operations only; no table lookups) |

### Notes

- `random_bytes` panics on CSPRNG failure — there is no safe fallback.
- HKDF output is bounded to 255 × 32 = **8160 bytes** max (RFC 5869 §2.3).
- HMAC accepts keys of any length (RFC 2104 pads/hashes if > block size).
- AEAD tag mismatch is constant-time; the partially decrypted buffer is zeroized on failure.
- `aead_decrypt` returns `Err(AeadFailed)` (not `Err(InvalidLength)`) when `ciphertext.len() < 16` — leaking whether a ciphertext was "too short" vs "bad tag" would create a length oracle. **CAPI divergence**: `soliton_aead_decrypt` with `ciphertext_len == 0` returns `SOLITON_ERR_INVALID_LENGTH` (-1) — the zero-length guard fires before the AEAD layer. Lengths 1–15 reach the AEAD layer and return `SOLITON_ERR_AEAD` (-4), consistent with the Rust API.
- Decrypted plaintext is `Zeroizing<Vec<u8>>` — automatically zeroized on drop.

### Raw Signature and KEM Primitives

`soliton::primitives` also exports four low-level algorithm modules. These bypass the composite identity key layer and are intended for custom protocol extensions — prefer `identity::hybrid_sign`/`hybrid_verify` and `xwing` for normal use. **No CAPI equivalents exist** for these modules — use `soliton_identity_sign`/`soliton_identity_verify` and `soliton_xwing_*` from the higher-level APIs instead.

**Secret key public API summary** (all types have `ZeroizeOnDrop`):

| Type | `as_bytes()` public? | `from_bytes()` public? |
|------|----------------------|------------------------|
| `xwing::SecretKey` | ✓ | ✓ |
| `mldsa::SecretKey` | ✗ (`pub(crate)`) | ✓ — seed only; any 32-byte input accepted |
| `mlkem::SecretKey` | ✗ (`pub(crate)`) | ✗ (`pub(crate)`) — opaque; use `keygen()` |

```rust
// ── Ed25519 (primitives::ed25519) ────────────────────────────────────────────
pub const SIGNATURE_SIZE: usize = 64;
pub const PUBLIC_KEY_SIZE: usize = 32;
pub const SECRET_KEY_SIZE: usize = 32;

// Returns ed25519_dalek types — add the exact pinned version to your [dependencies]:
//   ed25519-dalek = "=x.y.z"   // must match soliton's pinned version exactly; check soliton/Cargo.lock for the current pin.
//   A mismatch causes type errors (ed25519_dalek::SigningKey from one version is not the same type as from another).
pub fn keygen() -> (ed25519_dalek::VerifyingKey, ed25519_dalek::SigningKey)
pub fn sign(sk: &ed25519_dalek::SigningKey, message: &[u8]) -> [u8; 64]
// Deterministic per RFC 8032 §5.1.6 — same key + same message always produces the same signature.
// Contrast with ML-DSA-65 sign, which uses a randomized nonce (hedged).
pub fn verify(vk: &ed25519_dalek::VerifyingKey, message: &[u8], sig: &[u8; 64]) -> Result<()>
// verify uses verify_strict — rejects non-canonical signatures and small-order public keys.

// ── X25519 (primitives::x25519) ──────────────────────────────────────────────
pub struct PublicKey(/* [u8; 32] */);  // Clone, PartialEq, Eq
pub struct SecretKey(/* [u8; 32] */);  // !Clone + ZeroizeOnDrop

impl SecretKey { pub fn from_bytes(bytes: [u8; 32]) -> Self }  // takes [u8;32], not Vec; not fallible
// No public as_bytes() on x25519::SecretKey — SecretKey bytes are not externally extractable.
// Contrast with xwing::SecretKey which does have a public as_bytes().
impl PublicKey  {
    pub fn from_bytes(bytes: [u8; 32]) -> Self  // not fallible
    pub fn as_bytes(&self) -> &[u8; 32]
}

pub fn keygen() -> (PublicKey, SecretKey)
pub fn public_from_secret(sk: &SecretKey) -> PublicKey
pub fn dh(sk: &SecretKey, pk: &PublicKey) -> Result<[u8; 32]>
// dh returns Err(DecapsulationFailed) if Diffie-Hellman output is the all-zero point (low-order key).

// ── ML-DSA-65 (primitives::mldsa) — FIPS 204 ────────────────────────────────
pub struct PublicKey(/* Vec<u8> 1952 B */);  // Clone, PartialEq, Eq (variable-time Vec comparison — contrast with xwing::PublicKey which uses subtle::ConstantTimeEq; mldsa public keys are not secret)
impl PublicKey {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 1952
}
pub struct SecretKey(/* Vec<u8> 32-B seed */);  // ZeroizeOnDrop; seed-form, re-expanded per sign
// from_bytes IS public (unlike mlkem::SecretKey); as_bytes() is pub(crate) — external callers
// cannot extract the seed back out. Store the seed bytes before calling from_bytes if round-trip
// access is needed; there is no way to recover them from a SecretKey after construction.
impl SecretKey {
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 32 (seed size)
    // Any 32-byte input is accepted — semantic validity is only checked at sign time (all 32-byte
    // seeds are valid ML-DSA-65 seeds per FIPS 204 §6.1). No public as_bytes().
    // On Err, the input Vec is zeroized before returning (same guarantee as IdentitySecretKey::from_bytes
    // and xwing::SecretKey::from_bytes — Zeroizing wrapper fires on the error path).
}
pub struct Signature(/* Vec<u8> 3309 B */);  // Clone, PartialEq, Eq (variable-time; do not use == for verification — ML-DSA is hedged: same message signed twice produces different bytes)
impl Signature {
    pub fn as_bytes(&self) -> &[u8]
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // Err(InvalidLength) if len ≠ 3309
}

pub const fn pk_len() -> usize  // 1952
pub const fn sk_len() -> usize  // 32 (seed only)
pub const fn sig_len() -> usize // 3309

pub fn keygen() -> Result<(PublicKey, SecretKey)>
pub fn sign(sk: &SecretKey, message: &[u8]) -> Result<Signature>   // hedged: uses randomized nonce — same message signed twice produces different signatures; guards against catastrophic nonce reuse if RNG is weak. No constant-time guarantees: FIPS 204 permits variable-time polynomial operations; do not sign secret-dependent messages in timing-sensitive contexts.
pub fn verify(pk: &PublicKey, message: &[u8], sig: &Signature) -> Result<()>
// Uses sign_internal / verify_internal — INCOMPATIBLE with standalone FIPS 204 verifiers.
// Soliton ML-DSA signatures must always be verified by soliton (or a Specification.md reimplementation).

// ── ML-KEM-768 (primitives::mlkem) — FIPS 203 ───────────────────────────────
pub struct PublicKey(/* Vec<u8> 1184 B */);   // Clone, PartialEq, Eq (variable-time; public material)
impl PublicKey {
    pub fn as_bytes(&self) -> &[u8]                    // returns 1184-byte slice
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // InvalidLength if len ≠ 1184
}
pub struct SecretKey(/* Vec<u8> 2400 B */);   // ZeroizeOnDrop; expanded form; neither from_bytes nor as_bytes are public (both are pub(crate) only) — the SK is opaque once constructed; construct via keygen() only
pub struct Ciphertext(/* Vec<u8> 1088 B */);  // Clone, PartialEq, Eq (variable-time; public material)
impl Ciphertext {
    pub fn as_bytes(&self) -> &[u8]                    // returns 1088-byte slice
    pub fn from_bytes(bytes: Vec<u8>) -> Result<Self>  // InvalidLength if len ≠ 1088
}
pub struct SharedSecret(/* [u8; 32] */);      // ZeroizeOnDrop
impl SharedSecret {
    pub fn as_bytes(&self) -> &[u8; 32]
}
// SharedSecret: !Clone + ZeroizeOnDrop — extract bytes with as_bytes() if you need to copy them.

pub const fn pk_len() -> usize  // 1184
pub const fn sk_len() -> usize  // 2400
pub const fn ct_len() -> usize  // 1088

pub fn keygen() -> Result<(PublicKey, SecretKey)>
pub fn encapsulate(pk: &PublicKey) -> Result<(Ciphertext, SharedSecret)>
pub fn decapsulate(sk: &SecretKey, ct: &Ciphertext) -> Result<SharedSecret>
// ML-KEM uses implicit rejection — decapsulate never returns DecapsulationFailed in practice.
```

## Argon2id — Password-Based Key Derivation

Derive a cryptographic key from a passphrase. Use to encrypt identity keys at rest. **Not used internally by the protocol KDFs** — provided for application-layer key protection.

```rust
pub struct Argon2Params { pub m_cost: u32, pub t_cost: u32, pub p_cost: u32 }  // Copy + Clone + Debug — pass by value

impl Argon2Params {
    pub const OWASP_MIN: Self;    // 19 MiB, t=2, p=1 — interactive auth
    pub const RECOMMENDED: Self;  // 64 MiB, t=3, p=4 — stored keypair protection; NOT for WASM (OOM risk)
    pub const WASM_DEFAULT: Self; // 16 MiB, t=3, p=1 — WASM/memory-constrained environments
}

pub fn argon2id(password: &[u8], salt: &[u8], params: Argon2Params, out: &mut [u8]) -> Result<()>
```

| Property | Value |
|----------|-------|
| Algorithm | Argon2id (RFC 9106, §4) |
| Salt minimum | 8 bytes (16-32 random bytes recommended) |
| Output | 1-4096 bytes; caller-allocated |
| Presets | `OWASP_MIN` (19 MiB / t=2 / p=1), `RECOMMENDED` (64 MiB / t=3 / p=4 — **not for WASM**), `WASM_DEFAULT` (16 MiB / t=3 / p=1) |
| `InvalidLength` | salt < 8 bytes; `out` empty or > 4096 bytes; password has no minimum — empty password is accepted |
| `InvalidData` | cost params exceed upper bounds (m\_cost > 4,194,304 KiB, t\_cost > 256, p\_cost > 256) — explicit guards in the library; or violate the argon2 crate's internal minimums (e.g. m\_cost too low, t\_cost/p\_cost below 1, m\_cost/p\_cost ratio) — delegated to `Params::new()` and subject to argon2 crate minimums |
| `Internal` | `hash_password_into` returned an error after `Params::new` succeeded — structurally unreachable; indicates an upstream library bug |

### C API

Preset values for m\_cost/t\_cost/p\_cost (matching the Rust `Argon2Params` constants):

| Preset | m\_cost | t\_cost | p\_cost |
|--------|---------|---------|---------|
| `OWASP_MIN` | 19456 | 2 | 1 |
| `RECOMMENDED` | 65536 | 3 | 4 |
| `WASM_DEFAULT` | 16384 | 3 | 1 |

```c
int32_t soliton_argon2id(const uint8_t *password, uintptr_t password_len,
                           const uint8_t *salt, uintptr_t salt_len,
                           uint32_t m_cost, uint32_t t_cost, uint32_t p_cost,
                           uint8_t *out, uintptr_t out_len);
```

**Notes:**
- Zeroize `out` after use — caller responsibility.
- On failure, `out` is zeroized before returning — callers reusing the buffer on error receive zeroed bytes, not partial key material.
- The `argon2` crate's internal working memory blocks are zeroized on drop (enabled via the `zeroize` feature in `Cargo.toml`). The `password` slice is never copied or retained after the call returns.
- Generate salt with `primitives::random::random_array::<16>()`.

## Streaming AEAD — Chunked Encryption for Large Payloads

> **Import**: `use soliton::streaming;` — this is a top-level module, not `soliton::primitives::streaming`.

Encrypt/decrypt large files in 1 MiB chunks with XChaCha20-Poly1305. Supports random-access decryption and optional per-chunk zstd compression.

| Property | Value |
|----------|-------|
| Algorithm | XChaCha20-Poly1305, counter-derived nonces |
| Chunk size | 1 MiB (1,048,576 bytes) |
| Header | 26 bytes (version + flags + 24-byte nonce) |
| Per-chunk overhead | 17 bytes (tag_byte + Poly1305 tag) |
| Max encrypted chunk | 1,048,849 bytes (with zstd overhead) — CAPI: `out_len >= SOLITON_STREAM_ENCRYPT_MAX` |
| Decrypt output buffer minimum | 1,048,576 bytes — CAPI: `out_len >= SOLITON_STREAM_CHUNK_SIZE` |
| Compression | Optional zstd (Fastest level), per-chunk independent |
| Random access | `decrypt_chunk_at(index, chunk)` — no sequential processing required |
| Domain label | `"lo-stream-v1"` (12 bytes) |

### Rust API

```rust
pub fn stream_encrypt_init(key: &[u8; 32], aad: &[u8], compress: bool) -> Result<StreamEncryptor>
//   Infallible — always returns Ok. (Nonce generation panics on CSPRNG failure rather than returning Err.)

impl StreamEncryptor {
    pub fn header(&self) -> [u8; STREAM_HEADER_SIZE]
    //   STREAM_HEADER_SIZE = 26 (soliton::constants); write header before any chunks
    pub fn encrypt_chunk(&mut self, plaintext: &[u8], is_last: bool) -> Result<Vec<u8>>
    //   Sequential. Returns tag_byte (1) || aead_output (plaintext_len + 16).
    //   Non-final chunk: plaintext must be exactly STREAM_CHUNK_SIZE (1,048,576) bytes → InvalidData otherwise.
    //   Final chunk (is_last=true): plaintext may be 0–1,048,576 bytes.
    //   Errors do not advance the chunk index. Returns InvalidData if called after finalization.
    //   Returns ChainExhausted if chunk index reaches u64::MAX.
    pub fn encrypt_chunk_at(&self, index: u64, is_last: bool, plaintext: &[u8]) -> Result<Vec<u8>>
    //   Random-write; &self — stateless, does not advance next_index or set finalized.
    //   Non-final chunk: plaintext must be exactly STREAM_CHUNK_SIZE (1,048,576) bytes → InvalidData otherwise.
    //   Final chunk (is_last=true): plaintext may be 0–1,048,576 bytes.
    //   Does not enforce the post-finalization guard — can be called after sequential finalization.
    //   Thread-safe: takes &self, so multiple threads can call encrypt_chunk_at concurrently
    //   on the same encryptor (the CAPI reentrancy guard does not apply to the Rust API).
    //   SECURITY: the (index, is_last) pair must never duplicate any prior encrypt_chunk /
    //   encrypt_chunk_at call — nonce reuse under the same epoch key is catastrophic.
    //   WARNING: do NOT mix encrypt_chunk and encrypt_chunk_at on the same encryptor.
    //   encrypt_chunk advances next_index; encrypt_chunk_at does not. Calling encrypt_chunk
    //   for indices 0–N and then encrypt_chunk_at(K ≤ N, ...) silently reuses index K's nonce.
    pub fn is_finalized(&self) -> bool
    //   true after encrypt_chunk with is_last=true
}

pub fn stream_decrypt_init(key: &[u8; 32], header: &[u8; STREAM_HEADER_SIZE], aad: &[u8]) -> Result<StreamDecryptor>
// No compress parameter — decompression is automatic: the decryptor reads the compression flag
// from the header's flags byte (bit 0). If set, each chunk is decompressed after AEAD decryption.
// Err(UnsupportedVersion) — header version byte is not 0x01.
// Err(AeadFailed) — reserved flag bits (bits 1-7) are set (collapsed from a distinct error to prevent an oracle).

impl StreamDecryptor {
    pub fn decrypt_chunk(&mut self, chunk: &[u8]) -> Result<(Zeroizing<Vec<u8>>, bool)>
    //   Sequential. Returns (plaintext, is_last). Zeroizing — plaintext is secret.
    //   Returns InvalidData if called after finalization. Returns ChainExhausted if chunk index reaches u64::MAX.
    //   Returns AeadFailed if chunk is shorter than 17 bytes (1 tag_byte + 16 Poly1305 tag = STREAM_CHUNK_OVERHEAD).
    //   Returns InvalidData if a non-final non-compressed chunk has the wrong size
    //     (expected ciphertext portion chunk[1:] = STREAM_CHUNK_SIZE + AEAD_TAG_SIZE = 1,048,592 bytes;
    //     total wire bytes including tag_byte = 1,048,593; both shorter and longer inputs return InvalidData).
    //   Compressed non-final chunk with decompressed size ≠ STREAM_CHUNK_SIZE returns AeadFailed
    //     (checked post-decrypt+decompress; collapsed to AeadFailed to prevent a size oracle).
    //   On error, internal state is unchanged (next_index not advanced).
    pub fn decrypt_chunk_at(&self, index: u64, chunk: &[u8]) -> Result<(Zeroizing<Vec<u8>>, bool)>
    //   Random access; &self — stateless, does not advance expected_index.
    //   Can be called after finalization.
    //   Same size validation as decrypt_chunk: AeadFailed if chunk < 17 bytes; InvalidData if
    //   non-final non-compressed chunk[1:] is not exactly STREAM_CHUNK_SIZE + AEAD_TAG_SIZE bytes;
    //   AeadFailed if compressed non-final chunk decompresses to ≠ STREAM_CHUNK_SIZE bytes.
    //   "Stateless" means next_index is not advanced — validation still applies.
    pub fn is_finalized(&self) -> bool
    //   true after a chunk with tag_byte=0x01 was successfully decrypted via decrypt_chunk
    pub fn expected_index(&self) -> u64
    //   Next sequential chunk index expected by decrypt_chunk; does not advance on error
}
```

### C API

```c
// Note: compress and is_last are bool (C99 _Bool / stdbool.h), not int32_t.

int32_t soliton_stream_encrypt_init(const uint8_t *key, uintptr_t key_len,
                                     const uint8_t *aad, uintptr_t aad_len,
                                     bool compress,
                                     struct SolitonStreamEncryptor **out);
// Errors: NULL_POINTER (-13) — key or out null; aad null with aad_len > 0;
//         INVALID_LENGTH (-1) — key_len != 32 or aad_len > 256 MiB.

int32_t soliton_stream_encrypt_header(const struct SolitonStreamEncryptor *enc,
                                      uint8_t *out,
                                      uintptr_t out_len);  // out_len must be >= 26 (minimum, not exact); flat buffer, not SolitonBuf

int32_t soliton_stream_encrypt_chunk(struct SolitonStreamEncryptor *enc,  // mutable — reentrancy guard returns CONCURRENT_ACCESS on concurrent calls
                                     const uint8_t *plaintext, uintptr_t plaintext_len,
                                     bool is_last,
                                     uint8_t *out, uintptr_t out_len,     // out_len >= 1,048,849
                                     uintptr_t *out_written);

int32_t soliton_stream_encrypt_chunk_at(const struct SolitonStreamEncryptor *enc,  // const, but NOT concurrent-safe in CAPI — reentrancy guard returns CONCURRENT_ACCESS
                                        uint64_t index,
                                        const uint8_t *plaintext, uintptr_t plaintext_len,
                                        bool is_last,
                                        uint8_t *out, uintptr_t out_len,            // out_len >= 1,048,849
                                        uintptr_t *out_written);

int32_t soliton_stream_encrypt_is_finalized(const struct SolitonStreamEncryptor *enc,
                                             bool *out);

int32_t soliton_stream_encrypt_free(struct SolitonStreamEncryptor **enc);
// Nulls *enc on free. Returns INVALID_DATA (-17) on magic check failure; CONCURRENT_ACCESS (-18) if in use;
// 0 if enc (outer pointer) is null (no-op); 0 if *enc is null (no-op).

int32_t soliton_stream_decrypt_init(const uint8_t *key, uintptr_t key_len,
                                     const uint8_t *header, uintptr_t header_len,  // header_len must be 26
                                     const uint8_t *aad, uintptr_t aad_len,
                                     struct SolitonStreamDecryptor **out);
// Errors: NULL_POINTER (-13) — key, header, or out null; aad null with aad_len > 0;
//         INVALID_LENGTH (-1) — key_len != 32, header_len != 26, or aad_len > 256 MiB;
//         UNSUPPORTED_VERSION (-10) — header version byte != 0x01;
//         AEAD_FAILED (-4) — reserved flag bits (bits 1–7) set (collapsed to prevent oracle).

int32_t soliton_stream_decrypt_chunk(struct SolitonStreamDecryptor *dec,   // mutable — reentrancy guard returns CONCURRENT_ACCESS on concurrent calls
                                     const uint8_t *chunk, uintptr_t chunk_len,  // chunk_len must be >= 17 (tag_byte + Poly1305 tag); shorter → AeadFailed
                                     uint8_t *out, uintptr_t out_len,            // out_len >= 1,048,576
                                     uintptr_t *out_written, bool *is_last);

int32_t soliton_stream_decrypt_chunk_at(const struct SolitonStreamDecryptor *dec,   // const, but NOT concurrent-safe in CAPI — reentrancy guard returns CONCURRENT_ACCESS
                                        uint64_t index,
                                        const uint8_t *chunk, uintptr_t chunk_len,  // chunk_len must be >= 17; shorter → AeadFailed
                                        uint8_t *out, uintptr_t out_len,             // out_len >= 1,048,576
                                        uintptr_t *out_written, bool *is_last);

int32_t soliton_stream_decrypt_is_finalized(const struct SolitonStreamDecryptor *dec,
                                             bool *out);

int32_t soliton_stream_decrypt_expected_index(const struct SolitonStreamDecryptor *dec,
                                              uint64_t *out);

int32_t soliton_stream_decrypt_free(struct SolitonStreamDecryptor **dec);
// Nulls *dec on free. Returns INVALID_DATA (-17) on magic check failure; CONCURRENT_ACCESS (-18) if in use;
// 0 if dec (outer pointer) is null (no-op); 0 if *dec is null (no-op).
```

**`encrypt_chunk_at` / `decrypt_chunk_at` parameter order differs between Rust and CAPI**: in the Rust API `is_last` precedes `plaintext` (`encrypt_chunk_at(&self, index, is_last, plaintext)`); in the CAPI `plaintext`/`plaintext_len` precede `is_last` (`soliton_stream_encrypt_chunk_at(enc, index, plaintext, plaintext_len, is_last, ...)`). Same inversion applies to `decrypt_chunk_at`.

**`out_written` on error**: `soliton_stream_encrypt_chunk`, `soliton_stream_encrypt_chunk_at`, `soliton_stream_decrypt_chunk`, and `soliton_stream_decrypt_chunk_at` set `*out_written = 0` before any error return — callers do not need to guard against stale values.

**`soliton_stream_encrypt_chunk` / `soliton_stream_encrypt_chunk_at` NULL plaintext exemption**: passing `plaintext = NULL` with `plaintext_len == 0` is valid — both functions treat it as an empty slice (zero-length final chunk). By contrast, `soliton_stream_decrypt_chunk` / `soliton_stream_decrypt_chunk_at` unconditionally null-check `chunk` — NULL chunk always returns `NULL_POINTER` regardless of `chunk_len`.

**All streaming CAPI functions** return `NULL_POINTER` (-13) for null required arguments (see encrypt/decrypt chunk exemption above) and `INVALID_LENGTH` (-1) for buffer size mismatches (`key_len != 32`, `header_len != 26`, `out_len` below minimum). `soliton_stream_encrypt_init` and `soliton_stream_decrypt_init` additionally enforce the 256 MiB cap on `aad_len` → `INVALID_LENGTH` (same MAX_AAD_LEN as other CAPI functions). `soliton_stream_encrypt_chunk` and `soliton_stream_decrypt_chunk` additionally return `INVALID_DATA` (-17) if called after finalization and `CHAIN_EXHAUSTED` (-15) if the chunk index reaches `u64::MAX`. All four chunk functions (`soliton_stream_encrypt_chunk`, `soliton_stream_encrypt_chunk_at`, `soliton_stream_decrypt_chunk`, `soliton_stream_decrypt_chunk_at`) also return `INVALID_DATA` (-17) when a non-final chunk has the wrong plaintext/ciphertext size — consistent with the size validation in the Rust API above.

**Notes:**
- Key is caller-provided (typically a random per-file key encrypted in a ratchet message).
- **Per-chunk AAD construction** (for reimplementors): each XChaCha20-Poly1305 call receives:
  ```
  "lo-stream-v1" || version (1 B) || flags (1 B) || base_nonce (24 B)
    || chunk_index (u64 BE, 8 B) || tag_byte (1 B) || caller_aad
  ```
  `flags` here is `header[1]` — the stream-level flags byte, identical across all chunks in a stream (not a per-chunk value). This binds the stream identity (base_nonce), chunk position, and finalization status to each ciphertext — preventing chunk reorder and cross-stream injection.
- **Per-chunk nonce derivation**: `nonce = base_nonce XOR mask`, where mask is:
  ```
  mask[0..8]  = chunk_index (u64 big-endian)
  mask[8]     = tag_byte (0x00 = non-final, 0x01 = final)
  mask[9..24] = 0x00 (zero padding)  // Rust exclusive end; covers bytes 9–23 (15 bytes)
  ```
  The mask is injective over `(index, tag_byte)` pairs, guaranteeing nonce uniqueness across all chunks in a stream.
- **AAD guidance**: passing empty `&[]` is valid but provides no binding to external context. Typical practice: pass a file path, session ID, or other application-specific identifier so that a ciphertext encrypted for one context cannot be replayed in another (e.g., the same key reused with a different file).
- Non-final chunks must be exactly 1,048,576 bytes of plaintext — `encrypt_chunk` / `encrypt_chunk_at` return `InvalidData` if this is violated. Final chunk may be `0..=1,048,576`.
- **Empty final chunk with compression**: even with `compress=true`, an empty final plaintext chunk is not compressed (zstd on empty input is degenerate). The chunk is AEAD-encrypted directly — the stream-level flags byte (with bit 0 = 1) is still present in the chunk's AAD unchanged, but the chunk data itself is not compressed.
- Chunk delimitation is caller/transport responsibility — the library does not embed length prefixes.
- **`stream_decrypt_init` errors**: returns `UnsupportedVersion` if the header version byte is not `0x01`; returns `AeadFailed` if reserved flag bits (bits 1–7) are set (collapsed from a distinct error to prevent an oracle distinguishing "bad flag" from "auth failure").
- Post-auth errors (decompression, size mismatch) collapse to `AeadFailed` (oracle prevention).
- **CAPI buffer sizes**: `SOLITON_STREAM_HEADER_SIZE` (26), `SOLITON_STREAM_CHUNK_SIZE` (1,048,576), and `SOLITON_STREAM_ENCRYPT_MAX` (1,048,849) are defined in `soliton.h`. Rust: `soliton::constants::STREAM_HEADER_SIZE` / `STREAM_CHUNK_SIZE` / `STREAM_ENCRYPT_MAX`.
- `soliton_stream_decrypt_expected_index` returns the next sequential chunk index the decryptor expects (useful for stream validation).
- `encrypt_chunk` / `decrypt_chunk` return `ChainExhausted` when the next chunk index IS `u64::MAX` (the last valid chunk was at index `u64::MAX - 1`; unreachable in practice at 1 MiB/chunk ≈ 18 exabytes, but documented for completeness). `encrypt_chunk_at` / `decrypt_chunk_at` have **no** `u64::MAX` guard — passing `index = u64::MAX` is valid and never returns `ChainExhausted`.
- **`StreamEncryptor: Send + Sync`** and **`StreamDecryptor: Send + Sync`** (auto-derived from their fields). Safe to hold across `.await` points in async code. Sequential operations (`encrypt_chunk`, `decrypt_chunk`) require `&mut self` — wrap in `Arc<Mutex<>>` for shared mutable access. Random-access operations (`encrypt_chunk_at`, `decrypt_chunk_at`) take `&self` and are safe to call concurrently from multiple threads on the same encryptor/decryptor (the CAPI reentrancy guard handles FFI callers).
