Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
174 KiB
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) | 1–4096 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 u8ReceivedSession: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:_freestruct functions (soliton_buf_free,soliton_encrypted_message_free,soliton_kex_initiated_session_free,soliton_kex_received_session_free,soliton_decoded_session_init_free) returnvoid;soliton_zeroizereturnsvoid;soliton_versionreturnsconst char *. (Handle_freefunctions —soliton_ratchet_free,soliton_keyring_free,soliton_call_keys_free,soliton_stream_encrypt_free,soliton_stream_decrypt_free— do returnint32_t; see Handle free semantics below.) -
Heap-allocated output uses
SolitonBuf— free withsoliton_buf_free. For string-returning functions (e.g.,soliton_identity_generate'sfingerprint_hex_out,soliton_verification_phrase),lenincludes the null terminator. -
Opaque handles (
SolitonRatchet *,SolitonKeyRing *, etc.) must be freed with their corresponding_freefunction. -
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 lenparameter — passing a buffer physically shorter thanlenis 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, ...)anddecrypt_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 returnSOLITON_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,
memcpyon the pointer) and then using both copies is UB, regardless of threading. ForSolitonRatchet *, two aliases share the internalsend_countcounter — 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:
_freefunctions 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 toNULLafter freeing — double-free is a safe no-op. These functions returnint32_t, notvoid— they can returnSOLITON_ERR_INVALID_DATA(-17) if the magic number check fails (wrong handle type), orSOLITON_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_freefunctions 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
_freefunctions (outer NULL= outer pointer is null; all functions null the inner pointer on success):Function Outer NULLWrong magic In-use soliton_ratchet_free0 (no-op) −17 −18 soliton_keyring_free0 (no-op) −17 −18 soliton_call_keys_free0 (no-op) −17 −18 soliton_stream_encrypt_free0 (no-op) −17 −18 soliton_stream_decrypt_free0 (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) returnsSOLITON_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 useconst uint8_t * + uintptr_t len; the following useconst char *with no length parameter — pass a null-terminated C string; passing a non-null-terminated buffer is undefined behavior.- KEX:
crypto_versioninsoliton_kex_verify_bundle,soliton_kex_initiate,soliton_kex_encode_session_init; alsoSolitonSessionInitWire.crypto_version(passed tosoliton_kex_receive). - Storage:
channel_idandsegment_idinsoliton_storage_encrypt/soliton_storage_decrypt;batch_idinsoliton_dm_queue_encrypt/soliton_dm_queue_decrypt.
- KEX:
-
GC pinning:
SolitonInitiatedSession/SolitonReceivedSessioncontain inline secret keys (root_key,initial_chain_key/chain_key) — see theWARNINGcomment on theSolitonInitiatedSessionstruct 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 Copy — init_alice/init_bob receive a copy; your stack variable still holds secret material. Call root_key.zeroize() immediately after the call. |
Ratchet Rust API |
| 3 | Using from_bytes instead of from_bytes_with_min_epoch |
No rollback protection — an attacker with access to stale blobs can substitute one silently. | Ratchet Serialize workflow |
| 4 | Passing saved_epoch as min_epoch (instead of saved_epoch - 1) |
Rejects the blob you just wrote — from_bytes_with_min_epoch uses strict greater-than. |
Ratchet Serialize workflow |
| 5 | Calling Rust to_bytes() without can_serialize() on a counter-maxed ratchet |
to_bytes() moves self; on ChainExhausted the ratchet is dropped. Call can_serialize() first and handle false before calling to_bytes(). |
Ratchet Serialize workflow |
| 6 | Calling free() directly on SolitonBuf.ptr (CAPI) |
ptr points into the middle of the allocation — free() on it skips the internal 8-byte header and is heap corruption. Use soliton_buf_free() exclusively. |
C API conventions |
| 7 | Aliasing a SolitonRatchet * pointer (CAPI) |
Sequential encrypt calls via two aliases reuse AEAD nonces — catastrophic ciphertext collision. One handle, one owner. |
C API conventions |
| 8 | Assuming soliton_ratchet_to_bytes nulls the handle on CHAIN_EXHAUSTED (CAPI) |
On CHAIN_EXHAUSTED, *ratchet is NOT nulled — the session is still usable. Send/receive more messages, then retry. Do not call _free on this error. |
Ratchet Serialize workflow |
| 9 | Expecting Bob's first encrypt() to skip the KEM step |
init_bob always sets ratchet_pending = true. Bob's first outgoing message always carries a kem_ct in the header. Transport framing must allow for an optional ciphertext on every message. |
Ratchet Notes |
| 10 | Passing an all-zero key to StorageKey::new or soliton_keyring_new |
InvalidData at construction — not silently at encryption. Ensure key material comes from a CSPRNG or KDF, not a zero-initialized buffer. |
All-zero rejection inventory |
| 11 | Not zeroizing the [u8; 32] source buffer after StorageKey::new |
[u8; 32] is Copy — StorageKey::new() received a bitwise copy; your stack variable still holds key material. Call key.zeroize() immediately after the call. |
Storage API |
Error Handling
| Variant / Name | Code | C Constant | Meaning |
|---|---|---|---|
| (success) | 0 | SOLITON_OK |
|
InvalidLength { expected: usize, got: usize } |
-1 | SOLITON_ERR_INVALID_LENGTH |
Input buffer had wrong size; expected is either an exact required size or (for length-prefix bounded fields) the maximum allowed size |
DecapsulationFailed |
-2 | SOLITON_ERR_DECAPSULATION |
KEM decapsulation failed (invalid ciphertext) |
VerificationFailed |
-3 | SOLITON_ERR_VERIFICATION |
Signature verification failed (Ed25519, ML-DSA-65, or hybrid) |
AeadFailed |
-4 | SOLITON_ERR_AEAD |
AEAD decryption failed (wrong key, tampered ciphertext, or wrong AAD) |
BundleVerificationFailed |
-5 | SOLITON_ERR_BUNDLE |
Pre-key bundle verification failed (IK mismatch or invalid SPK signature) |
TooManySkipped |
-6 | SOLITON_ERR_TOO_MANY_SKIPPED |
(reserved — not currently returned; see footnote) |
DuplicateMessage |
-7 | SOLITON_ERR_DUPLICATE |
Message already decrypted or behind receiver's counter |
| (retired) | -8 | SOLITON_ERR_SKIPPED_KEY (removed) |
Retired legacy code — gap preserved for ABI stability; never returned |
AlgorithmDisabled |
-9 | SOLITON_ERR_ALGORITHM |
(reserved — not currently returned; see footnote) |
UnsupportedVersion |
-10 | SOLITON_ERR_VERSION |
Serialized blob has unsupported version |
DecompressionFailed |
-11 | SOLITON_ERR_DECOMPRESSION |
(reserved — not currently returned; see footnote) |
Internal |
-12 | SOLITON_ERR_INTERNAL |
Structurally unreachable internal invariant violated (bug in soliton) |
NullPointer |
-13 | SOLITON_ERR_NULL_POINTER |
Null pointer passed for a required argument (CAPI-only) |
UnsupportedFlags |
-14 | SOLITON_ERR_FLAGS |
(reserved — not currently returned; see footnote) |
ChainExhausted |
-15 | SOLITON_ERR_CHAIN_EXHAUSTED |
Counter-space exhausted. Sources: ratchet send/recv counter reaches u32::MAX; call key chain advance limit (2²⁴ = 16,777,216 steps); per-epoch recv_seen set reaches 65536 distinct entries; streaming chunk index reaches u64::MAX |
UnsupportedCryptoVersion |
-16 | SOLITON_ERR_CRYPTO_VERSION |
Peer advertised a crypto version this library does not implement |
InvalidData |
-17 | SOLITON_ERR_INVALID_DATA |
Structurally invalid content (format error, co-presence violation, implausible values) |
ConcurrentAccess |
-18 | SOLITON_ERR_CONCURRENT_ACCESS |
Opaque handle is in use by another concurrent operation (reentrancy guard) (CAPI-only) |
NullPointer and ConcurrentAccess are SolitonError variants (CAPI-only enum in soliton_capi) — they have no soliton::Error equivalent and cannot appear in a match on Err(Error::...).
Error implements std::error::Error + Display (via thiserror) — usable with ? into Box<dyn std::error::Error>, eprintln!("{}", e), and any standard error-reporting infrastructure.
Error is #[non_exhaustive] — exhaustive match arms will fail to compile when new variants are added; always include a wildcard:
match ratchet.encrypt(plaintext) {
Ok(msg) => send(msg),
Err(Error::ChainExhausted) => rekey(),
Err(e) => return Err(e), // wildcard required — Error is #[non_exhaustive]
}
Reserved variants footnote: -6 TooManySkipped, -9 AlgorithmDisabled, -11 DecompressionFailed, and -14 UnsupportedFlags exist for ABI stability but are not currently constructed by any code path. TooManySkipped was a pre-counter-mode artefact with no corresponding failure mode in the current implementation. AlgorithmDisabled has no active code paths. DecompressionFailed and UnsupportedFlags are intentionally collapsed to AeadFailed (oracle prevention — a distinct error would let an attacker distinguish a bad ciphertext from a valid-but-oversized one). -8 is a permanently retired legacy code (SOLITON_ERR_SKIPPED_KEY, removed); the gap is preserved to avoid changing the meaning of any persisted error codes.
Note: soliton_storage_decrypt and soliton_dm_queue_decrypt collapse all internal failures (decompression, unsupported flags, invalid data, version mismatch) to SOLITON_ERR_AEAD (-4) to prevent error-oracle attacks. Reachable error codes from these functions: SOLITON_ERR_NULL_POINTER (-13), SOLITON_ERR_CONCURRENT_ACCESS (-18), SOLITON_ERR_INVALID_LENGTH (-1, blob size out of bounds; also recipient_fp_len != 32 for soliton_dm_queue_decrypt), SOLITON_ERR_INVALID_DATA (-17, invalid UTF-8 in channel/segment/batch ID, or magic check failure — means a wrong opaque handle type was passed, e.g. a SolitonRatchet * where a SolitonKeyRing * is expected; there is only one keyring type, so passing a community blob to soliton_dm_queue_decrypt is not detected here — wrong AAD causes SOLITON_ERR_AEAD), SOLITON_ERR_AEAD (-4, all other failures collapsed).
All-zero rejection inventory
Several functions perform constant-time all-zero checks on secret key inputs. All-zero values indicate uninitialized buffers or programming errors and would produce predictable key material — treating them as valid is a silent security failure.
Functions that reject all-zero inputs:
| Function | Rejected parameter(s) | Error | Rationale |
|---|---|---|---|
RatchetState::init_alice |
root_key or epoch_key |
InvalidData |
HKDF output; all-zero signals a programming error. Check is constant-time (secret material). |
RatchetState::init_alice |
local_fp or remote_fp |
InvalidData |
All-zero fingerprint cannot roundtrip through ratchet serialization. |
RatchetState::init_bob |
root_key or epoch_key |
InvalidData |
Same as init_alice. |
RatchetState::init_bob |
local_fp or remote_fp |
InvalidData |
Same as init_alice. |
RatchetState::encrypt / decrypt / to_bytes |
internal root_key |
InvalidData |
Session-dead guard — root_key is zeroed by reset() and after any encrypt/decrypt error. Returning InvalidData rather than Internal signals recoverable state (re-establish session via KEX). |
RatchetState::from_bytes / from_bytes_with_min_epoch |
parsed root_key |
InvalidData |
Deserialization guard — rejects blobs that were serialized from a dead (post-reset) ratchet. |
StorageKey::new |
key |
InvalidData |
All-zero XChaCha20-Poly1305 key provides no confidentiality. Check is constant-time. |
derive_call_keys |
root_key |
InvalidData |
All-zero root key removes the ratchet binding from call keys. Check is constant-time. |
derive_call_keys |
kem_ss |
InvalidData |
All-zero KEM shared secret loses ephemeral forward secrecy. Check is constant-time. |
derive_call_keys |
call_id |
InvalidData |
All-zero call ID indicates an uninitialized buffer — variable-time check (call_id is non-secret). |
Functions that do NOT reject all-zero (size check only): IdentityPublicKey::from_bytes, IdentitySecretKey::from_bytes, HybridSignature::from_bytes, xwing::PublicKey::from_bytes, xwing::SecretKey::from_bytes, xwing::Ciphertext::from_bytes. These accept any bytes of the correct length — validation only happens when the key or ciphertext is actually used in a cryptographic operation.
Error recovery
What to do when each error variant is returned. "Re-establish" means start a new KEX from the beginning.
| Error | Typical cause | Recommended action |
|---|---|---|
AeadFailed (-4) |
Wrong decryption key; tampered or replayed ciphertext; wrong AAD; storage internal failure (collapsed) | Drop the message or blob. If persistent on a live ratchet, re-establish session. |
BundleVerificationFailed (-5) |
SPK signature invalid; identity key mismatch in bundle | Reject bundle. Do not proceed with KEX. Alert user — the key server or transport may be compromised. |
ChainExhausted (-15) |
Ratchet or call-key counter reached its limit; streaming chunk index at u64::MAX |
Re-establish session via new KEX. For streaming, start a new stream. |
DuplicateMessage (-7) |
Replayed or already-seen message counter | Drop silently. Replay attacks are not a reason to tear down the session. |
VerificationFailed (-3) |
Invalid hybrid signature (Ed25519 or ML-DSA-65 component) | Reject the signed object. Do not use any data derived from it. |
DecapsulationFailed (-2) |
Invalid KEM ciphertext | Reject. Do not retry with the same ciphertext. Re-establish session if this is a session-init message. |
InvalidData (-17) |
Malformed blob; all-zero key passed to init; co-presence violation; wrong handle type (CAPI) | Fix the caller. If an all-zero key triggered this, it is a programming error — do not retry without understanding why the key was zero. |
InvalidLength (-1) |
Buffer passed with wrong size; length exceeds 256 MiB cap | Fix the caller. Check sizes against the constants table. |
UnsupportedVersion (-10) |
Serialized blob written by a newer library version | Upgrade the library, or discard the blob if downgrade is required. No migration path — re-establish session. |
UnsupportedCryptoVersion (-16) |
Peer advertises a crypto_version string this library does not recognize |
Reject the peer. Update the library, or reject the connection. |
Internal (-12) |
Library invariant violated (should be unreachable) | Treat as fatal. File a bug report with reproduction steps. |
NullPointer (-13) (CAPI-only) |
Null pointer passed for a required argument | Fix the caller. Always check CAPI return codes before using output buffers. |
ConcurrentAccess (-18) (CAPI-only) |
Opaque handle already in use by another operation (reentrancy guard) | Retry after the in-flight operation completes. Use a mutex to serialize handle access across threads. The handle is not freed — do not call _free on an ConcurrentAccess error. |
Size constants
C constants (soliton.h #define): SOLITON_*_SIZE
Rust constants (soliton::constants): see the full table below — all C SOLITON_*_SIZE names have a constants:: Rust equivalent (e.g. SOLITON_PUBLIC_KEY_SIZE → LO_PUBLIC_KEY_SIZE). Prefer constants over hardcoded literals in allocation code.
| C Constant | Rust Constant | Value | Description |
|---|---|---|---|
SOLITON_PUBLIC_KEY_SIZE |
constants::LO_PUBLIC_KEY_SIZE |
3200 | IdentityPublicKey (X-Wing 1216 + Ed25519 32 + ML-DSA-65 1952) |
SOLITON_SECRET_KEY_SIZE |
constants::LO_SECRET_KEY_SIZE |
2496 | IdentitySecretKey (X-Wing 2432 + Ed25519 32 + ML-DSA-65 seed 32) |
SOLITON_XWING_PK_SIZE |
constants::XWING_PUBLIC_KEY_SIZE |
1216 | X-Wing public key |
SOLITON_XWING_SK_SIZE |
constants::XWING_SECRET_KEY_SIZE |
2432 | X-Wing secret key |
SOLITON_XWING_CT_SIZE |
constants::XWING_CIPHERTEXT_SIZE |
1120 | X-Wing ciphertext |
SOLITON_ED25519_SIG_SIZE |
constants::ED25519_SIGNATURE_SIZE |
64 | Ed25519 signature |
SOLITON_HYBRID_SIG_SIZE |
constants::HYBRID_SIGNATURE_SIZE |
3373 | Hybrid signature (Ed25519 64 + ML-DSA-65 3309) |
SOLITON_MLDSA_SIG_SIZE |
constants::MLDSA_SIGNATURE_SIZE |
3309 | ML-DSA-65 signature |
SOLITON_SHARED_SECRET_SIZE |
constants::SHARED_SECRET_SIZE |
32 | X-Wing / KEM shared secret |
SOLITON_FINGERPRINT_SIZE |
constants::FINGERPRINT_SIZE |
32 | SHA3-256 fingerprint |
SOLITON_AEAD_TAG_SIZE |
constants::AEAD_TAG_SIZE |
16 | XChaCha20-Poly1305 tag |
SOLITON_AEAD_NONCE_SIZE |
constants::AEAD_NONCE_SIZE |
24 | XChaCha20-Poly1305 nonce |
SOLITON_CALL_ID_SIZE |
constants::CALL_ID_SIZE |
16 | Call ID |
SOLITON_STREAM_HEADER_SIZE |
constants::STREAM_HEADER_SIZE |
26 | Streaming AEAD header (version + flags + 24-B nonce) |
SOLITON_STREAM_CHUNK_SIZE |
constants::STREAM_CHUNK_SIZE |
1,048,576 | Streaming plaintext chunk size (1 MiB) |
SOLITON_STREAM_ENCRYPT_MAX |
constants::STREAM_ENCRYPT_MAX |
1,048,849 | Max encrypted chunk (STREAM_CHUNK_SIZE + STREAM_ZSTD_OVERHEAD + STREAM_CHUNK_OVERHEAD) |
(Rust only — no #define in soliton.h) |
|||
| — | constants::STREAM_CHUNK_OVERHEAD |
17 | Per-chunk overhead (tag_byte + 16-B Poly1305 tag) |
| — | constants::STREAM_ZSTD_OVERHEAD |
256 | Worst-case zstd expansion per chunk |
| — | constants::STREAM_VERSION |
0x01 |
Streaming format version byte |
| — | constants::CRYPTO_VERSION |
"lo-crypto-v1" |
Protocol version string; use in PreKeyBundle.crypto_version and wire fields. Exact string match — verify_bundle, receive_session, and decode_session_init all reject any value that is not byte-for-byte "lo-crypto-v1" (no prefix or version-range comparison). |
| — | constants::RATCHET_BLOB_VERSION |
0x01 |
Version byte in serialized ratchet blobs; from_bytes* returns UnsupportedVersion if blob version ≠ this value. No migration path — old-version blobs are permanently unreadable; re-establish sessions via new KEX. |
Protocol domain labels (Rust only — soliton::constants)
Used internally by the library as HKDF info strings, HMAC labels, AAD prefixes, and signature context strings. Listed here for implementors producing wire-compatible code or verifying cross-implementation interoperability.
All constants have type &[u8] (byte-string literals). In Rust, use b"..." notation or reference the constant directly — not &str. constants::AUTH_HMAC_LABEL == b"lo-auth-v1" compiles; == "lo-auth-v1" does not.
| Constant | Value (&[u8]) |
Used in |
|---|---|---|
AUTH_HMAC_LABEL |
b"lo-auth-v1" |
KEM authentication HMAC (§4.2) |
KEX_HKDF_INFO_PFX |
b"lo-kex-v1" |
LO-KEX HKDF key derivation (§5.4) |
SPK_SIG_LABEL |
b"lo-spk-sig-v1" |
SPK signature context (§5.3) |
INITIATOR_SIG_LABEL |
b"lo-kex-init-sig-v1" |
SessionInit signature context (§5.4) |
RATCHET_HKDF_INFO |
b"lo-ratchet-v1" |
Ratchet root KDF (§6.4) |
DM_AAD |
b"lo-dm-v1" |
DM message AAD prefix (§7.3) |
STORAGE_AAD |
b"lo-storage-v1" |
Community storage AAD (§11.4.1) |
DM_QUEUE_AAD |
b"lo-dm-queue-v1" |
DM queue storage AAD (§11.4.2) |
CALL_HKDF_INFO |
b"lo-call-v1" |
Call key HKDF derivation (§6.12) |
PHRASE_HASH_LABEL |
b"lo-verification-v1" |
Verification phrase initial hash |
PHRASE_EXPAND_LABEL |
b"lo-phrase-expand-v1" |
Verification phrase expansion rehash |
STREAM_AAD |
b"lo-stream-v1" |
Streaming AEAD domain label (§15) |
Single-byte and fixed-value domain constants (also in soliton::constants, required for reimplementation):
| Constant | Type | Value | Used in |
|---|---|---|---|
MSG_KEY_DOMAIN_BYTE |
u8 |
0x01 |
Ratchet counter-mode message key: HMAC(epoch_key, 0x01 || counter_BE) where counter_BE is 4 bytes (u32 big-endian) — full input is 5 bytes |
| (0x02, 0x03 reserved) | — | — | Deliberately unassigned — gap between ratchet byte (0x01) and call bytes (0x04–0x06); reserved to prevent accidental reuse of former chain-mode derivation bytes. New protocol derivations must use 0x07 or higher. |
CALL_KEY_A_BYTE |
&[u8] |
&[0x04] |
Call key derivation, first call key |
CALL_KEY_B_BYTE |
&[u8] |
&[0x05] |
Call key derivation, second call key |
CALL_CHAIN_ADV_BYTE |
&[u8] |
&[0x06] |
Call chain key advance |
HKDF_ZERO_SALT |
[u8; 32] |
[0u8; 32] |
LO-KEX HKDF salt (§5.4 Step 4) — 32 zero bytes |
MAX_RECV_SEEN |
u32 |
65536 |
recv_seen set cap per epoch; 65536th distinct-counter decrypt succeeds, 65537th returns ChainExhausted |
MAX_CALL_ADVANCE (internal, not pub) |
u32 |
16,777,216 (2²⁴) |
CallKeys::advance() limit; ChainExhausted after this many calls. Not in soliton::constants — value for reimplementors only. |
Identity
Generates, signs with, and verifies using LO composite identity keys (X-Wing + ML-DSA-65). Provides KEM encapsulation/decapsulation for identity-bound key agreement.
| Type | Size (bytes) |
|---|---|
| IdentityPublicKey | 3200 (X-Wing pk 1216 + Ed25519 pk 32 + ML-DSA-65 pk 1952) |
| IdentitySecretKey | 2496 (X-Wing sk 2432 + Ed25519 sk 32 + ML-DSA-65 seed 32) |
| Fingerprint (raw) | 32 (SHA3-256 of public key bytes) |
| Fingerprint (hex) | 64 (lowercase hex) |
| HybridSignature | 3373 (Ed25519 64 + ML-DSA-65 3309) |
| X-Wing Ciphertext | 1120 (X25519 ephemeral 32 + ML-KEM-768 ct 1088) |
| X-Wing Shared Secret | 32 |
Rust API
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 fromfingerprint_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. IdentityPublicKeyimplementsPartialEqviasubtle::ConstantTimeEq(constant-time) and derivesEq. Does not implementHash— cannot be used directly inHashSet/HashMapwithout a custom wrapper.HybridSignaturederives standardPartialEq + Eq(variable-time, same asVec<u8>). Does not implementHash— not directly usable inHashSet/HashMap. Signatures are not secret, so timing risk is negligible; usehybrid_verifyfor any signature-verification path rather than==. Do not use==to verify: ML-DSA uses hedged (randomized) signing — two calls tohybrid_signon the same input produce different byte sequences, sosig_a == sig_bis always false even for the same message and key. Onlyhybrid_verifycan 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; returnstrue/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_challengeandauth_respondreturnZeroizing<[u8; 32]>, which wraps aCopytype. Dereferencing (let raw: [u8; 32] = *token) creates an unprotected copy that is not zeroized on drop. Access bytes via&*tokenortoken.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
InvalidLengthif either input is not exactly 3200 bytes — passing a 32-byte fingerprint instead of a full public key is the common mistake. - Returns
InvalidDataifpk_a == pk_b(same key passed twice — collapsed roles give no security). - Returns
Internalin 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)wherelower_key/higher_keyare 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 % 7776into the EFF large wordlist (exactly 7776 entries, generated bybuild.rsat compile time). When the hash is exhausted before 7 words are collected, expand:SHA3-256(b"lo-phrase-expand-v1" || round_byte || previous_hash)whereround_byteis a u8 counter starting at1. At most 19 rehash rounds (20 total rounds × 16 pairs = 320 samples; failure probability < 2⁻¹⁵⁰); returnsInternalif 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 viasign_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
- Bob publishes: IK_pub, SPK_pub, SPK_sig, OPK_pub (optional)
- Alice:
verify_bundle(bundle, known_bob_ik)— validates IK, verifies SPK_sig, checks crypto_version - Alice:
initiate_session(alice_ik, alice_ik_sk, &verified_bundle)→InitiatedSession { session_init: SessionInit, ek_pk, sender_sig, opk_used, ... }—root_keyandinitial_chain_keyare private; extract viasession.take_root_key()/session.take_initial_chain_key()(destructive: each zeroes its internal copy; a second call returns zeroed bytes) - Alice:
encode_session_init(&si)→ wire bytes;build_first_message_aad(sender_fp, recipient_fp, &si)→ AAD - Alice:
encrypt_first_message(initial_chain_key, plaintext, aad)→(encrypted_payload, ratchet_init_key) - Alice → Bob: encoded
SessionInit+sender_sig(3373 B) +encrypted_payload - Bob:
receive_session(...)→ReceivedSession— extracttake_root_key()/take_initial_chain_key()(same destructive semantics asInitiatedSession;peer_ekis public) - Bob:
build_first_message_aad(sender_fp, recipient_fp, &si)→ AAD;decrypt_first_message(initial_chain_key, encrypted_payload, aad)→(plaintext, ratchet_init_key) - 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_keyargument toinit_*isratchet_init_key(from step 5/8), not theinitial_chain_key/chain_keyfrom the KEX output.
- Alice:
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_sessioncompletes — advisory, not enforced by the library - OPK trust model:
verify_bundlechecks 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_ikencapsulation 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_ikandbob_ikare the full 3200-byte composite public keys;ekis Alice's 1216-byte ephemeral X-Wing public key.crypto_versionhere is always the literal bytes"lo-crypto-v1"(the library constant) — not the value received from the peer. Bothverify_bundleanddecode_session_initreject any other crypto_version before HKDF is computed, so the HKDF info is always built with the fixed 13-byte string. - KEX HKDF full call (for reimplementors):
HKDF-SHA3-256(salt=HKDF_ZERO_SALT, ikm=ss_ik || ss_spk [|| ss_opk], info=<info above>, L=64). IKM is the concatenation of the 32-byte X-Wing shared secrets from the IK, SPK, and optional OPK encapsulations — 64 bytes without OPK, 96 bytes with OPK. Output split: bytes 0–31 =root_key, bytes 32–63 =initial_chain_key. - SPK signing wire format (for reimplementors):
sign_prekeysignsb"lo-spk-sig-v1" || spk_pub.as_bytes()— the 14-byte label concatenated with the raw 1216-byte X-Wing public key.verify_bundleverifies the same construction. sender_sigwire format (for reimplementors):initiate_sessionsignsb"lo-kex-init-sig-v1" || encode_session_init(session_init)— the 18-byte label concatenated with the binary-encodedSessionInit.receive_sessionverifies 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_skfrominitiate_session→ callinit_alice. Firstencrypt()does not perform a KEM step — send keys are immediately active. - Bob (responder): has
peer_ekfromreceive_session→ callinit_bob. Firstencrypt()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 1–15 bytes have no minimum-length gate in the Rust API —
// all short inputs reach AEAD and return AeadFailed (not InvalidLength).
// Contrast: CAPI soliton_ratchet_decrypt returns INVALID_LENGTH for ciphertext_len == 0;
// 1–15 bytes pass the CAPI length gate and also return AEAD.
// First message (before ratchet is running)
#[must_use = "result contains the epoch key needed for ratchet initialization"]
pub fn encrypt_first_message(epoch_key: Zeroizing<[u8; 32]>, plaintext: &[u8], aad: &[u8])
-> Result<(Vec<u8>, Zeroizing<[u8; 32]>)>
// Returns (random 24-B nonce || ciphertext || 16-B Poly1305 tag, ratchet_init_key)
// Output length = len(plaintext) + 40 bytes. Minimum output for empty plaintext: 40 bytes (24 nonce + 0 plaintext + 16 tag).
// ratchet_init_key IS epoch_key — the input key is returned unchanged; pass it directly to init_alice/init_bob as the epoch_key argument.
// The 24-byte nonce is random, NOT counter-derived — distinct from ratchet message nonces ([0x00 × 20] || n.to_be_bytes()).
// Counter 0 is consumed by this call (kdf_msg_key(epoch_key, 0)). init_alice/init_bob therefore
// start their send/recv counters at 1, not 0. Never use counter 0 in the ratchet symmetric epoch.
// Err(AeadFailed) — structurally infallible for XChaCha20-Poly1305 (defense-in-depth only)
#[must_use = "result contains the epoch key needed for ratchet initialization"]
pub fn decrypt_first_message(epoch_key: Zeroizing<[u8; 32]>, encrypted_payload: &[u8], aad: &[u8])
-> Result<(Zeroizing<Vec<u8>>, Zeroizing<[u8; 32]>)>
// Returns (plaintext, ratchet_init_key) — ratchet_init_key IS epoch_key, returned unchanged.
// Err(AeadFailed) — wrong key, tampered ciphertext, wrong AAD, or payload too short (< 40 bytes)
// State management
pub fn to_bytes(self) -> Result<(Zeroizing<Vec<u8>>, u64)> // consumes self; returns (blob, epoch)
// Wire format is opaque and subject to change — treat the blob as a black box; use to_bytes/from_bytes exclusively. Cross-implementation compatibility is not guaranteed.
// WARNING: takes ownership (moves self). On Err, the ratchet is DROPPED (zeroized) — unlike the CAPI,
// there is no recovery path. Call can_serialize() first; if false, do NOT call to_bytes().
// Err(ChainExhausted) — send_count, recv_count, prev_send_count == u32::MAX, or epoch == u64::MAX
// Err(InvalidData) — recv_seen or prev_recv_seen has ≥ 65536 entries (defense-in-depth dead branch:
// decrypt's ChainExhausted guard fires at recv_seen.len() == 65536, preventing the set from growing
// further, so this InvalidData path is unreachable in normal operation; the global error table lists
// recv_seen exhaustion under ChainExhausted for this reason)
#[deprecated(note = "Use from_bytes_with_min_epoch for anti-rollback protection")]
pub fn from_bytes(data: &[u8]) -> Result<Self> // no rollback protection — always use from_bytes_with_min_epoch
// Err(InvalidData) — structural parse failure, zero/equal fingerprints, all-zero root_key
// (session was zeroized by reset() or AEAD failure — the zombie-blob guard), or invalid field values.
// Err(UnsupportedVersion) — blob version byte ≠ RATCHET_BLOB_VERSION.
// Err(ChainExhausted) — stored epoch == u64::MAX (defensive; a correct to_bytes never writes this, but from_bytes guards against crafted blobs).
pub fn from_bytes_with_min_epoch(data: &[u8], min_epoch: u64) -> Result<Self> // always use this; requires blob_epoch > min_epoch (strict); pass (epoch - 1) to load the blob that returned epoch
// Same errors as from_bytes, plus Err(InvalidData) when epoch ≤ min_epoch (rollback rejection).
pub fn epoch(&self) -> u64 // epoch stored in the next to_bytes() blob = epoch() + 1; see serialize/resume workflow
pub fn reset(&mut self) // zeroizes all key material in-place
// WARNING: to_bytes() after reset() succeeds but produces a zombie blob — a blob with all-zero
// root_key that is rejected by from_bytes/from_bytes_with_min_epoch with InvalidData. Do not
// persist a reset ratchet as if it were a live session.
pub fn can_serialize(&self) -> bool // false when send_count, recv_count, or prev_send_count == u32::MAX, epoch == u64::MAX, or a recv_seen/prev_recv_seen set has ≥ 65536 entries; reset() zeros counters to 0, not u32::MAX — does not trigger this check; safe to call after reset
pub fn derive_call_keys(&self, kem_ss: &[u8; 32], call_id: &[u8; 16]) -> Result<CallKeys> // see Call Keys section
} // end impl RatchetState
C API
int32_t soliton_ratchet_init_alice(const uint8_t *root_key, uintptr_t root_key_len,
const uint8_t *chain_key, uintptr_t chain_key_len,
const uint8_t *local_fp, uintptr_t local_fp_len,
const uint8_t *remote_fp, uintptr_t remote_fp_len,
const uint8_t *ek_pk, uintptr_t ek_pk_len,
const uint8_t *ek_sk, uintptr_t ek_sk_len,
struct SolitonRatchet **out);
// Errors: NULL_POINTER (-13); INVALID_LENGTH (-1) — wrong size for any field (root_key/chain_key/fp: 32,
// ek_pk: 1216, ek_sk: 2432); INVALID_DATA (-17) — all-zero root/chain key, equal fingerprints, or all-zero fingerprint.
// Security: root_key and chain_key are copied into ratchet state — zeroize your copies immediately after this call.
int32_t soliton_ratchet_init_bob(const uint8_t *root_key, uintptr_t root_key_len,
const uint8_t *chain_key, uintptr_t chain_key_len,
const uint8_t *local_fp, uintptr_t local_fp_len,
const uint8_t *remote_fp, uintptr_t remote_fp_len,
const uint8_t *peer_ek, uintptr_t peer_ek_len,
struct SolitonRatchet **out);
// Same errors as soliton_ratchet_init_alice (peer_ek: 1216; no ek_sk parameter).
// Security: same zeroization obligation for root_key and chain_key.
// Ratchet header (cleartext metadata accompanying each encrypted message)
struct SolitonRatchetHeader {
struct SolitonBuf ratchet_pk; // sender's X-Wing pk (1216 B; library-allocated)
struct SolitonBuf kem_ct; // KEM ct (1120 B; null if no ratchet step)
uint32_t n; // message counter within current send chain
uint32_t pn; // previous chain length
};
// Encrypted message returned from ratchet encrypt
struct SolitonEncryptedMessage {
struct SolitonRatchetHeader header;
struct SolitonBuf ciphertext; // library-allocated
};
// Fingerprints bound at init time — not passed per-call
int32_t soliton_ratchet_encrypt(struct SolitonRatchet *ratchet,
const uint8_t *plaintext, uintptr_t plaintext_len,
struct SolitonEncryptedMessage *out);
// Zero-length plaintext is valid — pass NULL for plaintext with plaintext_len == 0; produces 16-byte ciphertext (Poly1305 tag only).
// Errors: INVALID_DATA (-17) — ratchet was previously zeroized by reset or a prior AEAD failure (requires new KEX);
// CHAIN_EXHAUSTED (-15) — send_count at u32::MAX;
// AEAD (-4) — AEAD failed (session-fatal: state zeroized, all further calls return INVALID_DATA — call soliton_ratchet_free, not reset)
int32_t soliton_ratchet_decrypt(struct SolitonRatchet *ratchet,
const uint8_t *ratchet_pk, uintptr_t ratchet_pk_len,
const uint8_t *kem_ct, uintptr_t kem_ct_len, // null if absent
uint32_t n, uint32_t pn,
const uint8_t *ciphertext, uintptr_t ciphertext_len,
struct SolitonBuf *plaintext_out);
// Errors: DUPLICATE (-7); CHAIN_EXHAUSTED (-15) — counter at u32::MAX, or recv_seen / prev_recv_seen set reached 65536 entries;
// INVALID_LENGTH (-1) — ratchet_pk_len != 1216, kem_ct_len != 1120 when kem_ct non-null, ciphertext_len == 0, or > 256 MiB (see global cap note);
// INVALID_DATA (-17) — bad header (structurally invalid ratchet state);
// NULL_POINTER (-13) — kem_ct null with nonzero kem_ct_len, or vice versa (co-presence violation);
// DECAPSULATION (-2); AEAD (-4, state rolled back).
// Note: ciphertexts of 1–15 bytes are NOT caught by INVALID_LENGTH — they pass the length gate and return AEAD (-4).
int32_t soliton_ratchet_encrypt_first(const uint8_t *chain_key, uintptr_t chain_key_len,
const uint8_t *plaintext, uintptr_t plaintext_len,
const uint8_t *aad, uintptr_t aad_len,
struct SolitonBuf *payload_out,
uint8_t *ratchet_init_key_out,
uintptr_t ratchet_init_key_out_len); // 32 bytes
// Errors: NULL_POINTER (-13) — chain_key, payload_out, or ratchet_init_key_out null; or plaintext null
// with plaintext_len > 0; or aad null with aad_len > 0. NULL plaintext/aad with len == 0 is valid.
// INVALID_LENGTH (-1) — chain_key != 32 or ratchet_init_key_out_len != 32;
// AEAD (-4) — structurally infallible for XChaCha20 (defense-in-depth only).
int32_t soliton_ratchet_decrypt_first(const uint8_t *chain_key, uintptr_t chain_key_len,
const uint8_t *encrypted_payload, uintptr_t encrypted_payload_len,
const uint8_t *aad, uintptr_t aad_len,
struct SolitonBuf *plaintext_out,
uint8_t *ratchet_init_key_out,
uintptr_t ratchet_init_key_out_len); // 32 bytes
// Errors: NULL_POINTER (-13) — chain_key, encrypted_payload, plaintext_out, or ratchet_init_key_out null;
// or aad null with aad_len > 0. NULL aad with aad_len == 0 is valid.
// INVALID_LENGTH (-1) — chain_key != 32, ratchet_init_key_out_len != 32, or encrypted_payload_len == 0;
// AEAD (-4) — wrong key, tampered ciphertext, wrong AAD, or payload < 40 bytes (1–39 bytes → AeadFailed, not INVALID_LENGTH).
int32_t soliton_ratchet_to_bytes(struct SolitonRatchet **ratchet,
struct SolitonBuf *data_out,
uint64_t *epoch_out); // consumes ratchet; epoch_out may be NULL (but passing NULL discards the epoch, disabling anti-rollback protection — always pass a valid pointer)
// Errors: NULL_POINTER (-13); INVALID_DATA (-17) — magic check failure (wrong handle type passed);
// CONCURRENT_ACCESS (-18); CHAIN_EXHAUSTED (-15) — see below.
// Handle consumption: *ratchet is nulled unconditionally once Box::from_raw succeeds (after the
// can_serialize() gate), regardless of whether to_bytes() itself fails — the handle is irrecoverably
// consumed. NULL_POINTER, INVALID_DATA, CONCURRENT_ACCESS, and CHAIN_EXHAUSTED return before
// Box::from_raw and do NOT null *ratchet.
int32_t soliton_ratchet_from_bytes(const uint8_t *data, uintptr_t data_len,
struct SolitonRatchet **out);
// ^^^ avoid — no rollback protection. Use soliton_ratchet_from_bytes_with_min_epoch.
// Returns INVALID_LENGTH (-1) if data_len == 0 or data_len > 1 MiB (1,048,576 bytes) — tighter than the 256 MiB cap on other functions.
// Returns INVALID_DATA (-17) on structural parse failure, zero/equal fingerprints, or invalid field values.
// Returns UNSUPPORTED_VERSION (-10) if blob version byte ≠ RATCHET_BLOB_VERSION.
// Returns CHAIN_EXHAUSTED (-15) if the stored epoch is u64::MAX (defensive guard; correct to_bytes never writes this).
int32_t soliton_ratchet_from_bytes_with_min_epoch(const uint8_t *data, uintptr_t data_len,
uint64_t min_epoch,
struct SolitonRatchet **out);
// ^^^ always prefer this; pass (epoch_out - 1), NOT epoch_out — blob contains epoch_out, min_epoch must be strictly less.
// Same 1 MiB cap and same error codes as soliton_ratchet_from_bytes.
// Rollback rejection (epoch ≤ min_epoch) returns INVALID_DATA (-17).
int32_t soliton_ratchet_epoch(const struct SolitonRatchet *ratchet,
uint64_t *epoch_out);
// Returns the current epoch E (same semantics as Rust epoch()). soliton_ratchet_to_bytes stores E+1
// in the blob and returns epoch_out = E+1. DO NOT pass epoch_out directly as min_epoch — that rejects
// the blob you just wrote. Use min_epoch = epoch_out - 1 (the Quick Rule in the serialize workflow).
int32_t soliton_ratchet_reset(struct SolitonRatchet *ratchet);
// WARNING: a reset ratchet serializes successfully but contains zeroed key material.
// soliton_ratchet_reset() followed immediately by soliton_ratchet_to_bytes() produces
// a valid-looking blob that is rejected by soliton_ratchet_from_bytes with
// SOLITON_ERR_INVALID_DATA — the all-zero root_key check fires at deserialization.
// Do not store a reset ratchet as if it were a live session.
int32_t soliton_ratchet_free(struct SolitonRatchet **ratchet);
void soliton_encrypted_message_free(struct SolitonEncryptedMessage *msg);
// No soliton_ratchet_can_serialize — the Rust can_serialize() check is implicit in
// soliton_ratchet_to_bytes (returns CHAIN_EXHAUSTED on all can_serialize() failures).
// See "CAPI serialization guard" in the Notes section for details.
Notes
- Decrypt with rollback: on any error after state mutation, the full state snapshot (root key, epoch keys, ratchet keys, counters, recv_seen set) is atomically restored — the session is not corrupted on a bad message.
DuplicateMessageand recv_seenChainExhaustedare 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).ChainExhaustedfrom counter overflow (header.n == u32::MAX) andInvalidDatafrom 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)wherecounter_BEisn.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_seenset (HashSet<u32>, capped at 65536 entries — the runtime guard islen >= 65536before insert, so the 65536th distinct-counter decrypt succeeds (growing the set to 65536) and the 65537th distinct-counter decrypt returnsChainExhausted;can_serialize()returns false once the set reaches 65536). A seen counter returnsDuplicateMessage. Previous-epoch messages are handled via a one-epoch grace period (prev_recv_epoch_key); a separateprev_recv_seenset tracks them (also returnsDuplicateMessagefor 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()returnsAeadFailed. - Each side initialises its active counter to 1, not 0:
init_alicesetssend_count = 1(Alice's send path is immediately active);init_bobsetsrecv_count = 1(Bob's receive path is immediately active). The inactive counter on each side starts at 0. Counter 0 is reserved becauseencrypt_first_messageinternally callskdf_msg_key(epoch_key, 0)— the HMAC input for that key is0x01 || 0x00000000(5 bytes; counter_BE is the 4-byte big-endian representation of 0u32:[0x00, 0x00, 0x00, 0x00], consistent withMSG_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 hasheader.n = 1; Bob's first accepted message must also haven ≥ 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()— counternoccupies the last 4 bytes of the 24-byte XChaCha20 nonce; bytes 0–19 are zero. Each nonce is used with a unique per-message key (kdf_msg_key), so nonce distinctness only needs to hold within a single key's use. - Header in AAD — full layout: AAD is
b"lo-dm-v1" || sender_fp (32 B) || recipient_fp (32 B) || encode_ratchet_header(header), whereencode_ratchet_headerproduces:
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.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) - 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_bobsetsratchet_pending = trueandsend_epoch_key = [0u8; 32](a deliberate placeholder — never used for key derivation). Bob's firstencrypt()call always performs a full X-Wing keygen + encapsulate, overwritingsend_epoch_keybefore any message key is derived. This means Bob's first outgoing message always carries a KEM ciphertext in the header regardless of priordecrypt()calls. Thetest-utilsaccessorsend_epoch_key_ptr()on a freshly initialized Bob returns a pointer to zeroed bytes. By contrast, Alice starts withratchet_pending = false— her send keys are immediately active and her firstencrypt()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 areSend + Sync). Safe to transfer across threads or store inArc<Mutex<RatchetState>>. All mutating operations require&mut self, soArc<RwLock<RatchetState>>only permits concurrent reads — useArc<Mutex<RatchetState>>for any setup that includes encrypt or decrypt.RatchetState: !Clone— containsxwing::SecretKeywhich is!Clone. To checkpoint and restore session state, useto_bytes/from_bytes; there is no clone shortcut.
Serialize and resume workflow
to_bytes consumes the ratchet handle on success — it cannot be used after serialization. The returned epoch must be persisted alongside the blob for anti-rollback protection on reload.
Exception — SOLITON_ERR_CHAIN_EXHAUSTED: if soliton_ratchet_to_bytes returns this error, *ratchet is NOT consumed — the handle remains valid and the session is still usable. Send or receive additional messages to advance the ratchet counters, then retry serialization. Retrying soliton_ratchet_to_bytes without advancing the ratchet is safe: the can_serialize() pre-check fires again, returns SOLITON_ERR_CHAIN_EXHAUSTED, and the handle is not consumed.
For send_count overflow (send_count at u32::MAX): not recoverable — encrypt() checks send_count == u32::MAX before performing any KEM ratchet step, so even if the peer sends a new ratchet key (ratchet_pending=true), the ratchet step is never reached and send_count is never reset. Requires a new KEX. For recv_count or prev_send_count overflow (recv_count/prev_send_count at u32::MAX): recoverable — the peer sending a KEM ratchet step (NewEpoch decrypt) resets recv_count = 0; a subsequent local encrypt() that performs a KEM ratchet step resets prev_send_count to the current (lower) send_count. For recv_seen exhaustion (recv_seen/prev_recv_seen ≥ 65536 entries — also returns CHAIN_EXHAUSTED via the CAPI's can_serialize() pre-check): recoverable — the peer sending a KEM ratchet step (NewEpoch decrypt) resets the recv_seen set. Sending more messages from the local side does not clear recv_seen.
Quick rule: min_epoch = saved_epoch - 1. The blob stores epoch = saved_epoch; from_bytes_with_min_epoch requires blob_epoch > min_epoch (strict greater-than). Passing saved_epoch itself would reject the blob; passing saved_epoch - 2 would accept one stale epoch. saved_epoch is always ≥ 1 (to_bytes writes internal_epoch + 1; a fresh ratchet has internal epoch 0, so the first serialized blob has epoch 1) — the subtraction never underflows for u64 CAPI callers.
Initial save: a fresh ratchet has epoch() == 0; the first to_bytes stores epoch = 1 (i.e., epoch() + 1). Load it with min_epoch = 0 — there is no "previous save", so the floor is 0.
// Save:
(blob, epoch) = soliton_ratchet_to_bytes(&ratchet, &data_out, &epoch_out)
// On success: ratchet is now NULL — do NOT use it
// On CHAIN_EXHAUSTED: ratchet is still valid — continue sending/receiving, then retry
encrypt_and_store(data_out, epoch)
// Resume:
(data, saved_epoch) = load_and_decrypt()
// Pass saved_epoch - 1: blob contains epoch = saved_epoch, and min_epoch must be strictly less.
// This accepts the current blob and rejects any older blob (whose epoch < saved_epoch).
ratchet = soliton_ratchet_from_bytes_with_min_epoch(data, saved_epoch - 1)
// Continue encrypt/decrypt with the new handle
In the Rust API, to_bytes(self) takes ownership (moves). In the C API, soliton_ratchet_to_bytes nulls the SolitonRatchet ** pointer.
Rust save/load cycle (first save uses min_epoch = 0; subsequent saves use saved_epoch - 1):
// 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
- Caller generates an X-Wing keypair:
soliton_xwing_keygen()→(call_pk, call_sk) - Caller → Callee (over the existing ratchet channel): send
call_pk+ a randomcall_id(16 bytes). Generate withrandom::random_array::<16>()(Rust) orsoliton_random_bytes(call_id, 16)(CAPI). - Callee encapsulates:
soliton_xwing_encapsulate(call_pk)→(ct, kem_ss) - Callee → Caller: send
ct(1120 bytes) - Caller decapsulates:
soliton_xwing_decapsulate(call_sk, ct)→kem_ss - Both:
derive_call_keys(ratchet, kem_ss, call_id)→CallKeys { send_key, recv_key } - Both: use
send_key/recv_keyfor media encryption; calladvance()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. ReturnsChainExhaustedafter 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_keysbefore 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_keysreturnsInvalidDataif: ratchetroot_keyis all-zeros (post-reset()or after encrypt error),kem_ssis all-zeros (uninitialized buffer),call_idis all-zeros (uninitialized buffer), orlocal_fp == remote_fp(equal fingerprints collapse send/recv role separation).CallKeys: Send + Sync(auto-derived).advancerequires&mut self— useArc<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_encryptalways use the keyring's active key (set bysoliton_keyring_neworsoliton_keyring_add_keywithmake_active!=0). Adding a key withoutmake_activedoes not change which key encrypts. If no active key is present, both functions returnSOLITON_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_blobtake akey: &StorageKeydirectly — 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>andget_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_keyreturnsINVALID_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) callsoliton_keyring_add_key(keyring, new_key, new_key_len, 1)—make_active=1promotes 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, callsoliton_keyring_remove_key(keyring, old_version)to remove it. Do not remove a version while any live blobs reference it — decryption will fail withSOLITON_ERR_AEAD. - Community vs DM queue blobs are not interchangeable — the AAD domain labels differ (
lo-storage-v1vslo-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.versionis theStorageKey.versionbyte stored in the blob header (used bydecrypt_blobto look up the key).flagsbit 0 = zstd compression (plaintext compressed before AEAD if set; bits 1–7 reserved). Nonce is randomly generated per blob. Encryption:XChaCha20-Poly1305(key=StorageKey.key, nonce=blob_nonce, plaintext=data, aad=<aad below>). - Storage AAD wire format (for reimplementors):
- Community:
b"lo-storage-v1" || version (1 B) || flags (1 B) || u16_BE(len(channel_id)) || channel_id || u16_BE(len(segment_id)) || segment_id - DM queue:
b"lo-dm-queue-v1" || version (1 B) || flags (1 B) || u16_BE(len(recipient_fp)) || recipient_fp || u16_BE(len(batch_id)) || batch_id
- Community:
- Exact-match AAD:
channel_id,segment_id, andbatch_idare 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) takeint32_t compress(non-zero = enable); streaming functions (soliton_stream_encrypt_init) takebool compress(C99_Bool). Passing1works 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_blobandencrypt_dm_queue_blobreturnErr(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— useArc<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, passArc<Mutex<StorageKeyRing>>.StorageKeyitself derivesClone— 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::PublicKeyimplementsPartialEqviasubtle::ConstantTimeEq(constant-time) and derivesEq. Does not implementHash— not directly usable inHashSet/HashMap.xwing::CiphertextisCloneand derives standardPartialEq + Eq(variable-time). Ciphertexts are public, so timing risk is negligible. Does not implementHash— not directly usable inHashSet/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_bytespanics 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_decryptreturnsErr(AeadFailed)(notErr(InvalidLength)) whenciphertext.len() < 16— leaking whether a ciphertext was "too short" vs "bad tag" would create a length oracle. CAPI divergence:soliton_aead_decryptwithciphertext_len == 0returnsSOLITON_ERR_INVALID_LENGTH(-1) — the zero-length guard fires before the AEAD layer. Lengths 1–15 reach the AEAD layer and returnSOLITON_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
outafter use — caller responsibility. - On failure,
outis zeroized before returning — callers reusing the buffer on error receive zeroed bytes, not partial key material. - The
argon2crate's internal working memory blocks are zeroized on drop (enabled via thezeroizefeature inCargo.toml). Thepasswordslice 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, notsoliton::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 0–1,048,576 bytes.
// Errors do not advance the chunk index. Returns InvalidData if called after finalization.
// Returns ChainExhausted if chunk index reaches u64::MAX.
pub fn encrypt_chunk_at(&self, index: u64, is_last: bool, plaintext: &[u8]) -> Result<Vec<u8>>
// Random-write; &self — stateless, does not advance next_index or set finalized.
// Non-final chunk: plaintext must be exactly STREAM_CHUNK_SIZE (1,048,576) bytes → InvalidData otherwise.
// Final chunk (is_last=true): plaintext may be 0–1,048,576 bytes.
// Does not enforce the post-finalization guard — can be called after sequential finalization.
// Thread-safe: takes &self, so multiple threads can call encrypt_chunk_at concurrently
// on the same encryptor (the CAPI reentrancy guard does not apply to the Rust API).
// SECURITY: the (index, is_last) pair must never duplicate any prior encrypt_chunk /
// encrypt_chunk_at call — nonce reuse under the same epoch key is catastrophic.
// WARNING: do NOT mix encrypt_chunk and encrypt_chunk_at on the same encryptor.
// encrypt_chunk advances next_index; encrypt_chunk_at does not. Calling encrypt_chunk
// for indices 0–N and then encrypt_chunk_at(K ≤ N, ...) silently reuses index K's nonce.
pub fn is_finalized(&self) -> bool
// true after encrypt_chunk with is_last=true
}
pub fn stream_decrypt_init(key: &[u8; 32], header: &[u8; STREAM_HEADER_SIZE], aad: &[u8]) -> Result<StreamDecryptor>
// No compress parameter — decompression is automatic: the decryptor reads the compression flag
// from the header's flags byte (bit 0). If set, each chunk is decompressed after AEAD decryption.
// Err(UnsupportedVersion) — header version byte is not 0x01.
// Err(AeadFailed) — reserved flag bits (bits 1-7) are set (collapsed from a distinct error to prevent an oracle).
impl StreamDecryptor {
pub fn decrypt_chunk(&mut self, chunk: &[u8]) -> Result<(Zeroizing<Vec<u8>>, bool)>
// Sequential. Returns (plaintext, is_last). Zeroizing — plaintext is secret.
// Returns InvalidData if called after finalization. Returns ChainExhausted if chunk index reaches u64::MAX.
// Returns AeadFailed if chunk is shorter than 17 bytes (1 tag_byte + 16 Poly1305 tag = STREAM_CHUNK_OVERHEAD).
// Returns InvalidData if a non-final non-compressed chunk has the wrong size
// (expected ciphertext portion chunk[1:] = STREAM_CHUNK_SIZE + AEAD_TAG_SIZE = 1,048,592 bytes;
// total wire bytes including tag_byte = 1,048,593; both shorter and longer inputs return InvalidData).
// Compressed non-final chunk with decompressed size ≠ STREAM_CHUNK_SIZE returns AeadFailed
// (checked post-decrypt+decompress; collapsed to AeadFailed to prevent a size oracle).
// On error, internal state is unchanged (next_index not advanced).
pub fn decrypt_chunk_at(&self, index: u64, chunk: &[u8]) -> Result<(Zeroizing<Vec<u8>>, bool)>
// Random access; &self — stateless, does not advance expected_index.
// Can be called after finalization.
// Same size validation as decrypt_chunk: AeadFailed if chunk < 17 bytes; InvalidData if
// non-final non-compressed chunk[1:] is not exactly STREAM_CHUNK_SIZE + AEAD_TAG_SIZE bytes;
// AeadFailed if compressed non-final chunk decompresses to ≠ STREAM_CHUNK_SIZE bytes.
// "Stateless" means next_index is not advanced — validation still applies.
pub fn is_finalized(&self) -> bool
// true after a chunk with tag_byte=0x01 was successfully decrypted via decrypt_chunk
pub fn expected_index(&self) -> u64
// Next sequential chunk index expected by decrypt_chunk; does not advance on error
}
C API
// Note: compress and is_last are bool (C99 _Bool / stdbool.h), not int32_t.
int32_t soliton_stream_encrypt_init(const uint8_t *key, uintptr_t key_len,
const uint8_t *aad, uintptr_t aad_len,
bool compress,
struct SolitonStreamEncryptor **out);
// Errors: NULL_POINTER (-13) — key or out null; aad null with aad_len > 0;
// INVALID_LENGTH (-1) — key_len != 32 or aad_len > 256 MiB.
int32_t soliton_stream_encrypt_header(const struct SolitonStreamEncryptor *enc,
uint8_t *out,
uintptr_t out_len); // out_len must be >= 26 (minimum, not exact); flat buffer, not SolitonBuf
int32_t soliton_stream_encrypt_chunk(struct SolitonStreamEncryptor *enc, // mutable — reentrancy guard returns CONCURRENT_ACCESS on concurrent calls
const uint8_t *plaintext, uintptr_t plaintext_len,
bool is_last,
uint8_t *out, uintptr_t out_len, // out_len >= 1,048,849
uintptr_t *out_written);
int32_t soliton_stream_encrypt_chunk_at(const struct SolitonStreamEncryptor *enc, // const, but NOT concurrent-safe in CAPI — reentrancy guard returns CONCURRENT_ACCESS
uint64_t index,
const uint8_t *plaintext, uintptr_t plaintext_len,
bool is_last,
uint8_t *out, uintptr_t out_len, // out_len >= 1,048,849
uintptr_t *out_written);
int32_t soliton_stream_encrypt_is_finalized(const struct SolitonStreamEncryptor *enc,
bool *out);
int32_t soliton_stream_encrypt_free(struct SolitonStreamEncryptor **enc);
// Nulls *enc on free. Returns INVALID_DATA (-17) on magic check failure; CONCURRENT_ACCESS (-18) if in use;
// 0 if enc (outer pointer) is null (no-op); 0 if *enc is null (no-op).
int32_t soliton_stream_decrypt_init(const uint8_t *key, uintptr_t key_len,
const uint8_t *header, uintptr_t header_len, // header_len must be 26
const uint8_t *aad, uintptr_t aad_len,
struct SolitonStreamDecryptor **out);
// Errors: NULL_POINTER (-13) — key, header, or out null; aad null with aad_len > 0;
// INVALID_LENGTH (-1) — key_len != 32, header_len != 26, or aad_len > 256 MiB;
// UNSUPPORTED_VERSION (-10) — header version byte != 0x01;
// AEAD_FAILED (-4) — reserved flag bits (bits 1–7) set (collapsed to prevent oracle).
int32_t soliton_stream_decrypt_chunk(struct SolitonStreamDecryptor *dec, // mutable — reentrancy guard returns CONCURRENT_ACCESS on concurrent calls
const uint8_t *chunk, uintptr_t chunk_len, // chunk_len must be >= 17 (tag_byte + Poly1305 tag); shorter → AeadFailed
uint8_t *out, uintptr_t out_len, // out_len >= 1,048,576
uintptr_t *out_written, bool *is_last);
int32_t soliton_stream_decrypt_chunk_at(const struct SolitonStreamDecryptor *dec, // const, but NOT concurrent-safe in CAPI — reentrancy guard returns CONCURRENT_ACCESS
uint64_t index,
const uint8_t *chunk, uintptr_t chunk_len, // chunk_len must be >= 17; shorter → AeadFailed
uint8_t *out, uintptr_t out_len, // out_len >= 1,048,576
uintptr_t *out_written, bool *is_last);
int32_t soliton_stream_decrypt_is_finalized(const struct SolitonStreamDecryptor *dec,
bool *out);
int32_t soliton_stream_decrypt_expected_index(const struct SolitonStreamDecryptor *dec,
uint64_t *out);
int32_t soliton_stream_decrypt_free(struct SolitonStreamDecryptor **dec);
// Nulls *dec on free. Returns INVALID_DATA (-17) on magic check failure; CONCURRENT_ACCESS (-18) if in use;
// 0 if dec (outer pointer) is null (no-op); 0 if *dec is null (no-op).
encrypt_chunk_at / decrypt_chunk_at parameter order differs between Rust and CAPI: in the Rust API is_last precedes plaintext (encrypt_chunk_at(&self, index, is_last, plaintext)); in the CAPI plaintext/plaintext_len precede is_last (soliton_stream_encrypt_chunk_at(enc, index, plaintext, plaintext_len, is_last, ...)). Same inversion applies to decrypt_chunk_at.
out_written on error: soliton_stream_encrypt_chunk, soliton_stream_encrypt_chunk_at, soliton_stream_decrypt_chunk, and soliton_stream_decrypt_chunk_at set *out_written = 0 before any error return — callers do not need to guard against stale values.
soliton_stream_encrypt_chunk / soliton_stream_encrypt_chunk_at NULL plaintext exemption: passing plaintext = NULL with plaintext_len == 0 is valid — both functions treat it as an empty slice (zero-length final chunk). By contrast, soliton_stream_decrypt_chunk / soliton_stream_decrypt_chunk_at unconditionally null-check chunk — NULL chunk always returns NULL_POINTER regardless of chunk_len.
All streaming CAPI functions return NULL_POINTER (-13) for null required arguments (see encrypt/decrypt chunk exemption above) and INVALID_LENGTH (-1) for buffer size mismatches (key_len != 32, header_len != 26, out_len below minimum). soliton_stream_encrypt_init and soliton_stream_decrypt_init additionally enforce the 256 MiB cap on aad_len → INVALID_LENGTH (same MAX_AAD_LEN as other CAPI functions). soliton_stream_encrypt_chunk and soliton_stream_decrypt_chunk additionally return INVALID_DATA (-17) if called after finalization and CHAIN_EXHAUSTED (-15) if the chunk index reaches u64::MAX. All four chunk functions (soliton_stream_encrypt_chunk, soliton_stream_encrypt_chunk_at, soliton_stream_decrypt_chunk, soliton_stream_decrypt_chunk_at) also return INVALID_DATA (-17) when a non-final chunk has the wrong plaintext/ciphertext size — consistent with the size validation in the Rust API above.
Notes:
- Key is caller-provided (typically a random per-file key encrypted in a ratchet message).
- Per-chunk AAD construction (for reimplementors): each XChaCha20-Poly1305 call receives:
"lo-stream-v1" || version (1 B) || flags (1 B) || base_nonce (24 B) || chunk_index (u64 BE, 8 B) || tag_byte (1 B) || caller_aadflagshere isheader[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:
The mask is injective overmask[0..8] = chunk_index (u64 big-endian) mask[8] = tag_byte (0x00 = non-final, 0x01 = final) mask[9..24] = 0x00 (zero padding) // Rust exclusive end; covers bytes 9–23 (15 bytes)(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_atreturnInvalidDataif this is violated. Final chunk may be0..=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_initerrors: returnsUnsupportedVersionif the header version byte is not0x01; returnsAeadFailedif reserved flag bits (bits 1–7) are set (collapsed from a distinct error to prevent an oracle distinguishing "bad flag" from "auth failure").- Post-auth errors (decompression, size mismatch) collapse to
AeadFailed(oracle prevention). - CAPI buffer sizes:
SOLITON_STREAM_HEADER_SIZE(26),SOLITON_STREAM_CHUNK_SIZE(1,048,576), andSOLITON_STREAM_ENCRYPT_MAX(1,048,849) are defined insoliton.h. Rust:soliton::constants::STREAM_HEADER_SIZE/STREAM_CHUNK_SIZE/STREAM_ENCRYPT_MAX. soliton_stream_decrypt_expected_indexreturns the next sequential chunk index the decryptor expects (useful for stream validation).encrypt_chunk/decrypt_chunkreturnChainExhaustedwhen the next chunk index ISu64::MAX(the last valid chunk was at indexu64::MAX - 1; unreachable in practice at 1 MiB/chunk ≈ 18 exabytes, but documented for completeness).encrypt_chunk_at/decrypt_chunk_athave nou64::MAXguard — passingindex = u64::MAXis valid and never returnsChainExhausted.StreamEncryptor: Send + SyncandStreamDecryptor: Send + Sync(auto-derived from their fields). Safe to hold across.awaitpoints in async code. Sequential operations (encrypt_chunk,decrypt_chunk) require&mut self— wrap inArc<Mutex<>>for shared mutable access. Random-access operations (encrypt_chunk_at,decrypt_chunk_at) take&selfand are safe to call concurrently from multiple threads on the same encryptor/decryptor (the CAPI reentrancy guard handles FFI callers).