libsoliton/CHEATSHEET.md
Kamal Tufekcic 1d99048c95
Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled
initial commit
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
2026-04-02 23:48:10 +03:00

174 KiB
Raw Permalink Blame History

soliton Cheat Sheet

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

Contents: Overview · Errors · Identity · Auth · Verification · KEX · Ratchet · Call Keys · Storage · X-Wing · Primitives · Argon2id · Streaming AEAD


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) 14096 B output Passphrase/key protection; not used internally by the library

Rust import paths

# 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.

// 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).

// 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.

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 Copyinit_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 CopyStorageKey::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:

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_SIZELO_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 (0x040x06); 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

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

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

#[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

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

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

C API

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

// 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

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.

// ── 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 031 = root_key, bytes 3263 = 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

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 115 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;
//   115 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

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 115 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 (139 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 019 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 recoverableencrypt() 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):

// 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

// 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

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

// 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

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 17 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.

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

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

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.

// 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

// 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 115 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()
// ── 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.

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
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

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 01,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 01,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 0N 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

// 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 17) 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_lenINVALID_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 923 (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 17) 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).