//! C ABI export layer for soliton. //! //! This crate exposes soliton's public API as `extern "C"` functions //! with a stable C ABI. The generated `soliton.h` header is consumed //! by Go (cgo), C#/.NET (P/Invoke), Dart (dart:ffi), Objective-C, and C/C++. //! //! # Conventions //! //! - Return codes: `0` = success, negative = error (see `SolitonError`). //! - Caller-allocated output buffers are used when the size is known at compile time. //! - Library-allocated buffers use `SolitonBuf` — call `soliton_buf_free` when done. //! - Opaque state objects (`SolitonRatchet`, `SolitonKeyRing`) are heap-allocated //! and must be freed with their respective `_free` function. //! - All pointer arguments must be non-null unless documented otherwise. //! - Opaque handles (`SolitonRatchet*`, `SolitonKeyRing*`) are type-tagged with //! a magic number. Passing the wrong handle type returns //! `SOLITON_ERR_INVALID_DATA` (`-17`). Similarly, `_free` functions on the //! wrong handle type return `SOLITON_ERR_INVALID_DATA`. //! - Variable-length plaintext/ciphertext inputs are capped at 256 MiB //! (`268435456` bytes) to prevent allocation amplification from untrusted //! input. Functions exceeding this cap return `SOLITON_ERR_INVALID_LENGTH`. //! 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`. // Safety contracts for all `unsafe extern "C"` functions are documented in // `soliton.h` — the canonical interface consumed by C, Go, .NET, Dart, and // other non-Rust callers. Rust `# Safety` doc sections would be invisible to // those callers and would only duplicate the header. #![allow(clippy::missing_safety_doc)] use std::ffi::c_char; use std::slice; use std::sync::atomic::{AtomicBool, Ordering}; use soliton::identity::{self, HybridSignature, IdentityPublicKey, IdentitySecretKey}; use soliton::primitives::xwing; use zeroize::Zeroize; // ═══════════════════════════════════════════════════════════════════════ // Error codes // ═══════════════════════════════════════════════════════════════════════ /// Error codes returned by soliton C API functions. /// /// ## Discriminant table (complete, including reserved slots) /// /// ```text /// 0 Ok /// -1 InvalidLength /// -2 DecapsulationFailed /// -3 VerificationFailed /// -4 AeadFailed /// -5 BundleVerificationFailed /// -6 TooManySkipped /// -7 DuplicateMessage /// -8 RESERVED — previously assigned; must not be reused /// -9 AlgorithmDisabled /// -10 UnsupportedVersion /// -11 DecompressionFailed /// -12 Internal /// -13 NullPointer /// -14 UnsupportedFlags /// -15 ChainExhausted /// -16 UnsupportedCryptoVersion /// -17 InvalidData /// -18 ConcurrentAccess /// ``` #[repr(i32)] pub enum SolitonError { /// Success. Ok = 0, /// Invalid input length. InvalidLength = -1, /// KEM decapsulation failed. DecapsulationFailed = -2, /// Signature verification failed. VerificationFailed = -3, /// AEAD decryption failed. AeadFailed = -4, /// Pre-key bundle verification failed. BundleVerificationFailed = -5, /// Reserved — was skip cache overflow in the pre-counter-mode design. /// Retained for ABI stability: code -6 must not be reassigned. TooManySkipped = -6, /// Duplicate message. DuplicateMessage = -7, // -8 was previously assigned and may be stored in caller databases — // reusing it would silently change the meaning of persisted error codes. /// Algorithm disabled. AlgorithmDisabled = -9, /// Unsupported storage version. UnsupportedVersion = -10, /// Decompression failed. DecompressionFailed = -11, /// Internal error. Internal = -12, /// Null pointer argument. NullPointer = -13, /// Unsupported storage flags (reserved bits set). UnsupportedFlags = -14, /// Ratchet chain exhausted (counter overflow). ChainExhausted = -15, /// Unsupported crypto version in pre-key bundle. UnsupportedCryptoVersion = -16, /// Serialized data contains invalid markers or flags. InvalidData = -17, /// Concurrent access detected on an opaque handle. /// /// Opaque handles (`SolitonRatchet`, `SolitonKeyRing`, `SolitonCallKeys`) /// are NOT thread-safe. This error is returned when a second thread attempts /// to use a handle that is already in use. Callers must serialize access /// externally or use one handle per thread. ConcurrentAccess = -18, } fn error_to_code(e: soliton::error::Error) -> i32 { use soliton::error::Error; match e { Error::InvalidLength { .. } => SolitonError::InvalidLength as i32, Error::DecapsulationFailed => SolitonError::DecapsulationFailed as i32, Error::VerificationFailed => SolitonError::VerificationFailed as i32, Error::AeadFailed => SolitonError::AeadFailed as i32, Error::BundleVerificationFailed => SolitonError::BundleVerificationFailed as i32, Error::TooManySkipped => SolitonError::TooManySkipped as i32, Error::DuplicateMessage => SolitonError::DuplicateMessage as i32, Error::AlgorithmDisabled => SolitonError::AlgorithmDisabled as i32, Error::UnsupportedVersion => SolitonError::UnsupportedVersion as i32, Error::DecompressionFailed => SolitonError::DecompressionFailed as i32, Error::UnsupportedFlags => SolitonError::UnsupportedFlags as i32, Error::ChainExhausted => SolitonError::ChainExhausted as i32, Error::UnsupportedCryptoVersion => SolitonError::UnsupportedCryptoVersion as i32, Error::InvalidData => SolitonError::InvalidData as i32, Error::Internal => SolitonError::Internal as i32, // Error is #[non_exhaustive] — map any future variants to Internal. _ => SolitonError::Internal as i32, } } /// Unwrap a `Result`, or `return error_to_code(e)` on `Err`. /// /// Equivalent to the `?` operator but for functions returning `i32`. macro_rules! try_capi { ($expr:expr) => { match $expr { Ok(v) => v, Err(e) => return error_to_code(e), } }; } // ═══════════════════════════════════════════════════════════════════════ // Reentrancy guard for opaque handles // ═══════════════════════════════════════════════════════════════════════ /// RAII reentrancy guard for opaque CAPI handles. /// /// Each opaque handle (`SolitonRatchet`, `SolitonKeyRing`, `SolitonCallKeys`) /// carries an `AtomicBool` flag. `acquire` performs a CAS (false→true) on entry; /// the guard's `Drop` impl stores `false` on exit. This catches concurrent access /// from multiple threads — the most dangerous failure mode is AEAD nonce reuse /// from two threads encrypting on the same ratchet simultaneously. /// /// Cost: one atomic CAS per CAPI call (~1 ns), negligible vs the µs-ms /// cryptographic operations. struct ReentrancyGuard<'a>(&'a AtomicBool); impl<'a> ReentrancyGuard<'a> { fn acquire(flag: &'a AtomicBool) -> std::result::Result { if flag .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return Err(SolitonError::ConcurrentAccess as i32); } Ok(Self(flag)) } } impl Drop for ReentrancyGuard<'_> { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } /// Acquire the reentrancy guard on an opaque handle, returning /// `SOLITON_ERR_CONCURRENT_ACCESS` if the handle is already in use. macro_rules! acquire_guard { ($handle:expr, $magic:expr) => {{ // Validate type-tag discriminant before acquiring the reentrancy guard. // Catches cross-type handle confusion in weakly-typed bindings (Go // unsafe.Pointer, Python c_void_p, .NET IntPtr, Dart Pointer). if $handle.magic != $magic { return SolitonError::InvalidData as i32; } match ReentrancyGuard::acquire(&$handle.in_use) { Ok(g) => g, Err(code) => return code, } }}; } /// Validate that a caller-provided buffer length matches the expected /// fixed size. Returns `SOLITON_ERR_INVALID_LENGTH` on mismatch. /// /// Every CAPI function that reinterprets a raw pointer as a fixed-size /// array (`*const [u8; N]`) must call this macro first — without it, /// a binding that passes a shorter buffer causes a silent OOB read. macro_rules! check_len { ($len:expr, $expected:expr) => { if $len != $expected { return SolitonError::InvalidLength as i32; } }; } // ═══════════════════════════════════════════════════════════════════════ // Buffer type for library-allocated data // ═══════════════════════════════════════════════════════════════════════ /// Header prefix prepended to every library-allocated buffer. /// /// Stores the original allocation size at a fixed negative offset from the /// data pointer. This keeps deallocation metadata out of the `#[repr(C)]` /// struct visible to bindings — a binding cannot corrupt `alloc_len` without /// first performing an OOB write past the `ptr` it received, which is already /// UB regardless. /// /// Layout: `[alloc_len: u64 LE][data bytes...]` /// The data pointer returned to the caller points to byte 8 (after the header). const BUF_HEADER_SIZE: usize = std::mem::size_of::(); /// A library-allocated byte buffer. /// /// Must be freed with `soliton_buf_free`. Do not free `ptr` directly. /// The `ptr` field points to the data region; the original allocation size /// is stored in an internal header at `ptr - 8`, inaccessible to callers. #[repr(C)] pub struct SolitonBuf { pub ptr: *mut u8, pub len: usize, } impl SolitonBuf { /// Convert a `Vec` into a caller-owned `SolitonBuf`. /// /// Allocates `BUF_HEADER_SIZE + data.len()` bytes, stores the allocation /// length in the first 8 bytes, and returns a pointer to byte 8 as `ptr`. /// The caller sees only the data region; the header is invisible. fn from_vec(mut v: Vec) -> Self { let data_len = v.len(); // Build a single allocation: [alloc_len: u64 LE][data...] // checked_add: defense-in-depth against overflow on 32-bit targets // (all CAPI functions cap inputs at 256 MiB, so this cannot fail in practice). let total = BUF_HEADER_SIZE .checked_add(data_len) .expect("BUF_HEADER_SIZE + data_len overflow"); let mut buf = Vec::with_capacity(total); buf.extend_from_slice(&(total as u64).to_le_bytes()); buf.extend_from_slice(&v); // Zeroize the source Vec — callers pass plaintext or key material via // std::mem::take from Zeroizing>. take() extracts the Vec, // bypassing Zeroizing's drop. Without this, the original heap allocation // would be freed with sensitive data still in memory. v.zeroize(); // Shrink to exact size so Box::from_raw round-trip is layout-correct. let boxed = buf.into_boxed_slice(); let raw = Box::into_raw(boxed) as *mut u8; // Caller-visible pointer starts after the header. let data_ptr = unsafe { raw.add(BUF_HEADER_SIZE) }; SolitonBuf { ptr: data_ptr, len: data_len, } } /// Return an empty buffer (null ptr, zero len) for error paths. fn empty() -> Self { SolitonBuf { ptr: std::ptr::null_mut(), len: 0, } } } /// Free a library-allocated buffer, zeroizing its contents first. /// /// All buffers are zeroized before freeing to prevent sensitive data from /// lingering in freed heap memory. After calling this, the buffer's `ptr` /// is set to null and `len` to 0, making double-free a safe no-op. /// Safe to call with a null `buf` pointer or a buf whose `ptr` is null (no-op). /// /// Deallocation uses the internally stored allocation size (in the header /// at `ptr - 8`), not the caller-visible `len` — a buggy caller that /// modifies `len` cannot corrupt the heap or skip zeroization. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_buf_free(buf: *mut SolitonBuf) { if buf.is_null() { return; } let b = unsafe { &mut *buf }; if !b.ptr.is_null() { unsafe { // Recover the base pointer (before header) and read alloc_len. let base = b.ptr.sub(BUF_HEADER_SIZE); let mut header_bytes = [0u8; BUF_HEADER_SIZE]; std::ptr::copy_nonoverlapping(base, header_bytes.as_mut_ptr(), BUF_HEADER_SIZE); let alloc_len = u64::from_le_bytes(header_bytes) as usize; // Zeroize the entire allocation (header + data). let full_slice = slice::from_raw_parts_mut(base, alloc_len); full_slice.zeroize(); // SAFETY: base was allocated via Box::into_raw(Vec::into_boxed_slice()) // in SolitonBuf::from_vec. alloc_len matches the original boxed // slice length, so the Box<[u8]> round-trip layout is correct. drop(Box::from_raw(full_slice)); } } b.ptr = std::ptr::null_mut(); b.len = 0; } /// Zeroize a caller-owned memory region, guaranteed not to be optimized out. /// /// Standard C `memset` may be elided by the compiler if the buffer is not /// read afterward. This function delegates to the `zeroize` crate, which /// uses volatile writes to ensure the zeroing is never removed by /// optimization passes. FFI consumers should call this on any buffer that /// held secret material (e.g., chain keys copied out of `soliton_ratchet_encrypt_first`). /// /// `ptr`: pointer to the memory region to zeroize. /// `len`: number of bytes to zeroize. Zero is a valid no-op. /// /// Safe to call with a null `ptr` (no-op). #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_zeroize(ptr: *mut u8, len: usize) { if ptr.is_null() || len == 0 { return; } let buf = unsafe { slice::from_raw_parts_mut(ptr, len) }; buf.zeroize(); } /// Convert a C string pointer to a Rust &str, returning InvalidData on invalid UTF-8. /// /// # Safety /// `ptr` must be a valid, null-terminated C string pointer. unsafe fn cstr_to_str<'a>(ptr: *const c_char) -> std::result::Result<&'a str, i32> { unsafe { std::ffi::CStr::from_ptr(ptr) } .to_str() .map_err(|_| SolitonError::InvalidData as i32) } // ═══════════════════════════════════════════════════════════════════════ // Version // ═══════════════════════════════════════════════════════════════════════ /// Null-terminated version string, derived from Cargo.toml at compile time. const VERSION_CSTR: &std::ffi::CStr = { // SAFETY: CARGO_PKG_VERSION contains no interior nuls; concat! appends exactly one \0. unsafe { std::ffi::CStr::from_bytes_with_nul_unchecked( concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes(), ) } }; /// Return the soliton version string (null-terminated, static lifetime). #[unsafe(no_mangle)] pub extern "C" fn soliton_version() -> *const c_char { VERSION_CSTR.as_ptr() } // ═══════════════════════════════════════════════════════════════════════ // Constants // ═══════════════════════════════════════════════════════════════════════ /// LO composite public key size (bytes). pub const SOLITON_PUBLIC_KEY_SIZE: usize = soliton::constants::LO_PUBLIC_KEY_SIZE; /// LO composite secret key size (bytes). pub const SOLITON_SECRET_KEY_SIZE: usize = soliton::constants::LO_SECRET_KEY_SIZE; /// X-Wing public key size (bytes). pub const SOLITON_XWING_PK_SIZE: usize = soliton::constants::XWING_PUBLIC_KEY_SIZE; /// X-Wing secret key size (bytes). pub const SOLITON_XWING_SK_SIZE: usize = soliton::constants::XWING_SECRET_KEY_SIZE; /// X-Wing ciphertext size (bytes). pub const SOLITON_XWING_CT_SIZE: usize = soliton::constants::XWING_CIPHERTEXT_SIZE; /// Hybrid signature size (bytes). pub const SOLITON_HYBRID_SIG_SIZE: usize = soliton::constants::HYBRID_SIGNATURE_SIZE; /// Fingerprint size (raw SHA3-256, bytes). pub const SOLITON_FINGERPRINT_SIZE: usize = soliton::constants::FINGERPRINT_SIZE; /// Ed25519 signature size (bytes). pub const SOLITON_ED25519_SIG_SIZE: usize = soliton::constants::ED25519_SIGNATURE_SIZE; /// ML-DSA-65 signature size (bytes). pub const SOLITON_MLDSA_SIG_SIZE: usize = soliton::constants::MLDSA_SIGNATURE_SIZE; /// AEAD (XChaCha20-Poly1305) tag size (bytes). pub const SOLITON_AEAD_TAG_SIZE: usize = soliton::constants::AEAD_TAG_SIZE; /// AEAD (XChaCha20-Poly1305) nonce size (bytes). pub const SOLITON_AEAD_NONCE_SIZE: usize = soliton::constants::AEAD_NONCE_SIZE; /// Shared secret size (bytes). X-Wing produces a 32-byte shared secret. pub const SOLITON_SHARED_SECRET_SIZE: usize = soliton::constants::SHARED_SECRET_SIZE; /// Call ID size (bytes). Random per-call identifier for call key derivation. pub const SOLITON_CALL_ID_SIZE: usize = soliton::constants::CALL_ID_SIZE; /// Streaming header size (bytes). pub const SOLITON_STREAM_HEADER_SIZE: usize = soliton::constants::STREAM_HEADER_SIZE; /// Streaming chunk plaintext size (bytes, 1 MiB). pub const SOLITON_STREAM_CHUNK_SIZE: usize = soliton::constants::STREAM_CHUNK_SIZE; /// Worst-case encrypted chunk size (bytes). Callers allocate this for output. pub const SOLITON_STREAM_ENCRYPT_MAX: usize = soliton::constants::STREAM_ENCRYPT_MAX; // Compile-time guards: these must match the #define values in cbindgen.toml. // If a constant changes, update both the Rust const AND the cbindgen.toml #define. const _: () = assert!( SOLITON_PUBLIC_KEY_SIZE == 3200, "update cbindgen.toml SOLITON_PUBLIC_KEY_SIZE" ); const _: () = assert!( SOLITON_SECRET_KEY_SIZE == 2496, "update cbindgen.toml SOLITON_SECRET_KEY_SIZE" ); const _: () = assert!( SOLITON_XWING_PK_SIZE == 1216, "update cbindgen.toml SOLITON_XWING_PK_SIZE" ); const _: () = assert!( SOLITON_XWING_SK_SIZE == 2432, "update cbindgen.toml SOLITON_XWING_SK_SIZE" ); const _: () = assert!( SOLITON_XWING_CT_SIZE == 1120, "update cbindgen.toml SOLITON_XWING_CT_SIZE" ); const _: () = assert!( SOLITON_SHARED_SECRET_SIZE == 32, "update cbindgen.toml SOLITON_SHARED_SECRET_SIZE" ); const _: () = assert!( SOLITON_ED25519_SIG_SIZE == 64, "update cbindgen.toml SOLITON_ED25519_SIG_SIZE" ); const _: () = assert!( SOLITON_HYBRID_SIG_SIZE == 3373, "update cbindgen.toml SOLITON_HYBRID_SIG_SIZE" ); const _: () = assert!( SOLITON_MLDSA_SIG_SIZE == 3309, "update cbindgen.toml SOLITON_MLDSA_SIG_SIZE" ); const _: () = assert!( SOLITON_FINGERPRINT_SIZE == 32, "update cbindgen.toml SOLITON_FINGERPRINT_SIZE" ); const _: () = assert!( SOLITON_AEAD_TAG_SIZE == 16, "update cbindgen.toml SOLITON_AEAD_TAG_SIZE" ); const _: () = assert!( SOLITON_AEAD_NONCE_SIZE == 24, "update cbindgen.toml SOLITON_AEAD_NONCE_SIZE" ); const _: () = assert!( SOLITON_CALL_ID_SIZE == 16, "update cbindgen.toml SOLITON_CALL_ID_SIZE" ); const _: () = assert!( SOLITON_STREAM_HEADER_SIZE == 26, "update cbindgen.toml SOLITON_STREAM_HEADER_SIZE" ); const _: () = assert!( SOLITON_STREAM_CHUNK_SIZE == 1_048_576, "update cbindgen.toml SOLITON_STREAM_CHUNK_SIZE" ); const _: () = assert!( SOLITON_STREAM_ENCRYPT_MAX == 1_048_849, "update cbindgen.toml SOLITON_STREAM_ENCRYPT_MAX" ); // ═══════════════════════════════════════════════════════════════════════ // Primitives: random, SHA3-256 // ═══════════════════════════════════════════════════════════════════════ /// Fill `buf` with `len` cryptographically random bytes from the OS CSPRNG. /// /// `buf`: caller-allocated output buffer (at least `len` bytes). /// `len`: number of random bytes to generate. Zero is a valid no-op. /// Maximum 256 MiB (consistent with other CAPI length caps). /// /// Returns 0 on success, `NullPointer` if `buf` is null, /// `InvalidLength` if `len` exceeds 256 MiB. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_random_bytes(buf: *mut u8, len: usize) -> i32 { if buf.is_null() { return SolitonError::NullPointer as i32; } if len == 0 { return 0; } const MAX_RANDOM_LEN: usize = 256 * 1024 * 1024; if len > MAX_RANDOM_LEN { return SolitonError::InvalidLength as i32; } let out = unsafe { slice::from_raw_parts_mut(buf, len) }; soliton::primitives::random::random_bytes(out); 0 } /// Compute SHA3-256 hash. /// /// `data` / `data_len`: input data. Null with `data_len = 0` hashes the empty string. /// `out` / `out_len`: caller-allocated buffer, must be exactly 32 bytes. /// /// Returns 0 on success, `SOLITON_ERR_INVALID_LENGTH` if `out_len != 32` or /// `data_len > 256 MiB`, `SOLITON_ERR_NULL_POINTER` if `out` is null or /// `data` is null with `data_len != 0`. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_sha3_256( data: *const u8, data_len: usize, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } check_len!(out_len, 32); // Cap input length to prevent CPU DoS from untrusted input — // consistent with the 256 MiB cap on AEAD/ratchet/storage functions. const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if data_len > MAX_INPUT_LEN { unsafe { std::ptr::write_bytes(out, 0, 32) }; return SolitonError::InvalidLength as i32; } if data.is_null() && data_len != 0 { unsafe { std::ptr::write_bytes(out, 0, 32) }; return SolitonError::NullPointer as i32; } // Read data into a slice and compute hash BEFORE writing to out — if the // caller hashes in-place (data == out), zeroing out first would destroy // the input. let data = if data_len == 0 { &[] } else { unsafe { slice::from_raw_parts(data, data_len) } }; let hash = soliton::primitives::sha3_256::hash(data); unsafe { std::ptr::copy_nonoverlapping(hash.as_ptr(), out, 32) }; 0 } // ═══════════════════════════════════════════════════════════════════════ // Identity: keygen, sign, verify, encapsulate, decapsulate // ═══════════════════════════════════════════════════════════════════════ /// Generate a LO composite identity keypair. /// /// On success: /// - `pk_out` receives the public key bytes. /// - `sk_out` receives the secret key bytes. /// - `fingerprint_hex_out` receives the hex fingerprint (null-terminated, caller frees with `soliton_buf_free`). /// /// # Security /// /// `sk_out` contains the raw 2496-byte identity secret key. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// During construction, a brief two-copy window exists: the `IdentitySecretKey` /// (zeroized on drop) and the `SolitonBuf`-held copy coexist until the /// former drops at function return. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_generate( pk_out: *mut SolitonBuf, sk_out: *mut SolitonBuf, fingerprint_hex_out: *mut SolitonBuf, ) -> i32 { if pk_out.is_null() || sk_out.is_null() || fingerprint_hex_out.is_null() { return SolitonError::NullPointer as i32; } // Zero outputs so error paths leave them in a safe, freeable state. unsafe { std::ptr::write_bytes(pk_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(sk_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes( fingerprint_hex_out as *mut u8, 0, std::mem::size_of::(), ); } match identity::generate_identity() { Ok(id) => { unsafe { *pk_out = SolitonBuf::from_vec(id.public_key.as_bytes().to_vec()); *sk_out = SolitonBuf::from_vec(id.secret_key.as_bytes().to_vec()); // C callers expect null-terminated strings. let mut fp_bytes = id.fingerprint_hex.into_bytes(); fp_bytes.push(0); *fingerprint_hex_out = SolitonBuf::from_vec(fp_bytes); } 0 } Err(e) => error_to_code(e), } } /// Compute the raw fingerprint (SHA3-256) of a public key. /// /// `pk` / `pk_len`: identity public key (must be `SOLITON_PUBLIC_KEY_SIZE` bytes). /// `out` / `out_len`: caller-allocated buffer, must be exactly 32 bytes. /// /// Returns 0 on success, `InvalidLength` if `pk_len` or `out_len` is wrong. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_fingerprint( pk: *const u8, pk_len: usize, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } check_len!(out_len, 32); // Zero output so error paths (including the pk-null check below) leave // *out in a defined state. unsafe { std::ptr::write_bytes(out, 0, 32) }; if pk.is_null() { return SolitonError::NullPointer as i32; } check_len!(pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); let pk_bytes = unsafe { slice::from_raw_parts(pk, pk_len) }; match IdentityPublicKey::from_bytes(pk_bytes.to_vec()) { Ok(pk) => { let fp = pk.fingerprint_raw(); unsafe { std::ptr::copy_nonoverlapping(fp.as_ptr(), out, 32) }; 0 } Err(e) => error_to_code(e), } } /// Sign a message with hybrid Ed25519 + ML-DSA-65. /// /// `sk` / `sk_len`: secret key bytes. /// `message` / `message_len`: message to sign. /// `sig_out`: receives the signature buffer (caller frees with `soliton_buf_free`). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_sign( sk: *const u8, sk_len: usize, message: *const u8, message_len: usize, sig_out: *mut SolitonBuf, ) -> i32 { if sig_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(sig_out as *mut u8, 0, std::mem::size_of::()) }; if sk.is_null() { return SolitonError::NullPointer as i32; } // message may be null when message_len == 0 (signing an empty message is valid). if message.is_null() && message_len != 0 { return SolitonError::NullPointer as i32; } check_len!(sk_len, soliton::constants::LO_SECRET_KEY_SIZE); let sk_bytes = unsafe { slice::from_raw_parts(sk, sk_len) }; let msg = if message_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(message, message_len) } }; let secret_key = try_capi!(IdentitySecretKey::from_bytes(sk_bytes.to_vec())); match identity::hybrid_sign(&secret_key, msg) { Ok(sig) => { unsafe { *sig_out = SolitonBuf::from_vec(sig.as_bytes().to_vec()) }; 0 } Err(e) => error_to_code(e), } } /// Verify a hybrid signature (Ed25519 + ML-DSA-65). /// /// `pk` / `pk_len`: identity public key. /// `message` / `message_len`: signed message. Null with `message_len = 0` verifies an empty message. /// `sig` / `sig_len`: hybrid signature to verify. /// /// Returns 0 if valid, `VerificationFailed` (-3) if the signature is invalid, /// or another negative error code on parse failure. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_verify( pk: *const u8, pk_len: usize, message: *const u8, message_len: usize, sig: *const u8, sig_len: usize, ) -> i32 { if pk.is_null() || sig.is_null() || (message.is_null() && message_len != 0) { return SolitonError::NullPointer as i32; } check_len!(pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); check_len!(sig_len, soliton::constants::HYBRID_SIGNATURE_SIZE); let pk_bytes = unsafe { slice::from_raw_parts(pk, pk_len) }; let msg = if message_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(message, message_len) } }; let sig_bytes = unsafe { slice::from_raw_parts(sig, sig_len) }; let public_key = try_capi!(IdentityPublicKey::from_bytes(pk_bytes.to_vec())); let signature = try_capi!(HybridSignature::from_bytes(sig_bytes.to_vec())); match identity::hybrid_verify(&public_key, msg, &signature) { Ok(()) => 0, Err(e) => error_to_code(e), } } /// Encapsulate to an identity key's X-Wing component. /// /// `pk` / `pk_len`: identity public key. /// `ct_out`: receives X-Wing ciphertext (caller frees). /// `ss_out` / `ss_out_len`: must point to exactly 32 bytes for the shared secret. /// On error, `ss_out` is zeroed. Caller must check return code before using. /// /// # Security /// /// The 32-byte shared secret written to `ss_out` is raw key material. The /// caller must zeroize `ss_out` when it is no longer needed (e.g., /// `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_encapsulate( pk: *const u8, pk_len: usize, ct_out: *mut SolitonBuf, ss_out: *mut u8, ss_out_len: usize, ) -> i32 { if ct_out.is_null() || ss_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(ss_out_len, 32); unsafe { std::ptr::write_bytes(ct_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(ss_out, 0, 32); } if pk.is_null() { return SolitonError::NullPointer as i32; } check_len!(pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); let pk_bytes = unsafe { slice::from_raw_parts(pk, pk_len) }; let public_key = try_capi!(IdentityPublicKey::from_bytes(pk_bytes.to_vec())); match identity::encapsulate(&public_key) { Ok((ct, ss)) => { unsafe { *ct_out = SolitonBuf::from_vec(ct.as_bytes().to_vec()); std::ptr::copy_nonoverlapping(ss.as_bytes().as_ptr(), ss_out, 32); } 0 } Err(e) => error_to_code(e), } } /// Decapsulate using an identity key's X-Wing component. /// /// `sk` / `sk_len`: identity secret key. /// `ct` / `ct_len`: X-Wing ciphertext. /// `ss_out` / `ss_out_len`: must point to exactly 32 bytes for the shared secret. /// On error, `ss_out` is zeroed. Caller must check return code before using. /// /// # Security /// /// The 32-byte shared secret written to `ss_out` is raw key material. The /// caller must zeroize `ss_out` when it is no longer needed (e.g., /// `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_identity_decapsulate( sk: *const u8, sk_len: usize, ct: *const u8, ct_len: usize, ss_out: *mut u8, ss_out_len: usize, ) -> i32 { if ss_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(ss_out_len, 32); unsafe { std::ptr::write_bytes(ss_out, 0, 32) }; if sk.is_null() || ct.is_null() { return SolitonError::NullPointer as i32; } check_len!(sk_len, soliton::constants::LO_SECRET_KEY_SIZE); check_len!(ct_len, soliton::constants::XWING_CIPHERTEXT_SIZE); let sk_bytes = unsafe { slice::from_raw_parts(sk, sk_len) }; let ct_bytes = unsafe { slice::from_raw_parts(ct, ct_len) }; let secret_key = try_capi!(IdentitySecretKey::from_bytes(sk_bytes.to_vec())); let ciphertext = try_capi!(xwing::Ciphertext::from_bytes(ct_bytes.to_vec())); match identity::decapsulate(&secret_key, &ciphertext) { Ok(ss) => { unsafe { std::ptr::copy_nonoverlapping(ss.as_bytes().as_ptr(), ss_out, 32) }; 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // Auth: challenge, respond, verify // ═══════════════════════════════════════════════════════════════════════ /// Server-side: generate an authentication challenge. /// /// `client_pk` / `client_pk_len`: client's identity public key. /// `ct_out`: receives the X-Wing ciphertext (caller frees). /// `token_out` / `token_out_len`: must point to exactly 32 bytes for the expected token. /// /// # Security /// /// `token_out` receives the expected authentication token (secret). The caller /// must zeroize it after verifying the client's proof. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_auth_challenge( client_pk: *const u8, client_pk_len: usize, ct_out: *mut SolitonBuf, token_out: *mut u8, token_out_len: usize, ) -> i32 { if ct_out.is_null() || token_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(token_out_len, 32); unsafe { std::ptr::write_bytes(ct_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(token_out, 0, 32); } if client_pk.is_null() { return SolitonError::NullPointer as i32; } check_len!(client_pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); let pk_bytes = unsafe { slice::from_raw_parts(client_pk, client_pk_len) }; let public_key = try_capi!(IdentityPublicKey::from_bytes(pk_bytes.to_vec())); match soliton::auth::auth_challenge(&public_key) { Ok((ct, token)) => { unsafe { *ct_out = SolitonBuf::from_vec(ct.as_bytes().to_vec()); // token is Zeroizing<[u8; 32]> — copy bytes out, then drop zeroizes. std::ptr::copy_nonoverlapping(token.as_ptr(), token_out, 32); } 0 } Err(e) => error_to_code(e), } } /// Client-side: respond to an authentication challenge. /// /// `client_sk` / `client_sk_len`: client's identity secret key. /// `ct` / `ct_len`: ciphertext from the challenge. /// `proof_out` / `proof_out_len`: must point to exactly 32 bytes for the proof. /// /// # Security /// /// `proof_out` receives a sensitive authentication proof. The caller must /// zeroize it after transmitting. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_auth_respond( client_sk: *const u8, client_sk_len: usize, ct: *const u8, ct_len: usize, proof_out: *mut u8, proof_out_len: usize, ) -> i32 { if proof_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(proof_out_len, 32); unsafe { std::ptr::write_bytes(proof_out, 0, 32) }; if client_sk.is_null() || ct.is_null() { return SolitonError::NullPointer as i32; } check_len!(client_sk_len, soliton::constants::LO_SECRET_KEY_SIZE); check_len!(ct_len, soliton::constants::XWING_CIPHERTEXT_SIZE); let sk_bytes = unsafe { slice::from_raw_parts(client_sk, client_sk_len) }; let ct_bytes = unsafe { slice::from_raw_parts(ct, ct_len) }; let secret_key = try_capi!(IdentitySecretKey::from_bytes(sk_bytes.to_vec())); let ciphertext = try_capi!(xwing::Ciphertext::from_bytes(ct_bytes.to_vec())); match soliton::auth::auth_respond(&secret_key, &ciphertext) { Ok(proof) => { // proof is Zeroizing<[u8; 32]> — copy bytes out, then drop zeroizes. unsafe { std::ptr::copy_nonoverlapping(proof.as_ptr(), proof_out, 32) }; 0 } Err(e) => error_to_code(e), } } /// Server-side: verify an authentication proof. /// /// `expected_token`: 32 bytes. /// `proof`: 32 bytes. /// /// # Security /// /// Comparison is constant-time (`subtle::ConstantTimeEq`), preventing timing /// side-channels that leak information about the expected token. /// /// Returns 0 if valid, -3 (VerificationFailed) if invalid. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_auth_verify( expected_token: *const u8, expected_token_len: usize, proof: *const u8, proof_len: usize, ) -> i32 { if expected_token.is_null() || proof.is_null() { return SolitonError::NullPointer as i32; } check_len!(expected_token_len, 32); check_len!(proof_len, 32); // SAFETY: expected_token is non-null (checked above) and validated to be 32 bytes. let token: &[u8; 32] = unsafe { &*(expected_token as *const [u8; 32]) }; // SAFETY: proof is non-null (checked above) and validated to be 32 bytes. let prf: &[u8; 32] = unsafe { &*(proof as *const [u8; 32]) }; if soliton::auth::auth_verify(token, prf) { 0 } else { SolitonError::VerificationFailed as i32 } } // ═══════════════════════════════════════════════════════════════════════ // Ratchet: opaque state + encrypt/decrypt // ═══════════════════════════════════════════════════════════════════════ // ── Type-tag magic numbers ─────────────────────────────────────────────────── // Each opaque handle embeds a magic number as its first field. CAPI functions // validate the tag on entry to catch cross-type handle confusion in weakly-typed // bindings (Go unsafe.Pointer, Python c_void_p, .NET IntPtr, Dart Pointer). // Without this, passing the wrong handle type reads in_use at the wrong offset, // causing silent memory corruption in a crypto context. // Magic values for opaque handle type-tagging. Each pair differs in all // 4 bytes (Hamming distance 7-9 bits), so single-byte corruption cannot // transform one handle type into another. Previous values ("SOLR", // "SOLK", "SOLC") shared 3 bytes — differing only in the final byte. const MAGIC_RATCHET: u32 = 0x534F_4C52; // "SOLR" const MAGIC_KEYRING: u32 = 0x4B45_5952; // "KEYR" const MAGIC_CALLKEYS: u32 = 0x4341_4C4C; // "CALL" /// Opaque ratchet state. /// /// The `in_use` flag is a reentrancy guard — CAPI functions acquire it on /// entry and release on exit. Concurrent access from multiple threads returns /// `SOLITON_ERR_CONCURRENT_ACCESS` instead of silently proceeding (which /// would cause AEAD nonce reuse on encrypt). /// /// # Undefined Behavior /// /// **Do not duplicate handles.** Copying the pointer (e.g., via `memcpy` on /// the `SolitonRatchet*` variable, or assigning a second variable to the /// same address) and using both copies for encrypt/decrypt produces /// catastrophic nonce reuse — both copies share a `send_count` counter, /// and the reentrancy guard only prevents *concurrent* access, not /// *sequential* use from two copies. Nonce uniqueness depends on each /// ratchet handle having exactly one owner. pub struct SolitonRatchet { magic: u32, inner: soliton::ratchet::RatchetState, in_use: AtomicBool, } /// Ratchet header returned from encrypt, passed to decrypt. #[repr(C)] pub struct SolitonRatchetHeader { /// Sender's ratchet public key (library-allocated). pub ratchet_pk: SolitonBuf, /// KEM ciphertext, if present (library-allocated; ptr is null if absent). pub kem_ct: SolitonBuf, /// Message number within current send chain. pub n: u32, /// Length of the previous send chain. pub pn: u32, } /// Encrypted message returned from ratchet encrypt. #[repr(C)] pub struct SolitonEncryptedMessage { pub header: SolitonRatchetHeader, /// Ciphertext (library-allocated). pub ciphertext: SolitonBuf, } /// Initialize ratchet state for Alice (initiator). /// /// `root_key`: 32 bytes. /// `chain_key`: 32 bytes (initial epoch key for counter-mode derivation). /// `local_fp`: local identity fingerprint (32 bytes). /// `remote_fp`: remote identity fingerprint (32 bytes). /// `ek_pk` / `ek_pk_len`: Alice's ephemeral X-Wing public key. /// `ek_sk` / `ek_sk_len`: Alice's ephemeral X-Wing secret key. /// `out`: receives an opaque `SolitonRatchet*` (caller frees with `soliton_ratchet_free`). /// /// # Security /// /// `root_key` and `chain_key` are **copied** into the ratchet state. The caller /// should zeroize its copies immediately after this call (e.g., `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_init_alice( root_key: *const u8, root_key_len: usize, chain_key: *const u8, chain_key_len: usize, local_fp: *const u8, local_fp_len: usize, remote_fp: *const u8, remote_fp_len: usize, ek_pk: *const u8, ek_pk_len: usize, ek_sk: *const u8, ek_sk_len: usize, out: *mut *mut SolitonRatchet, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if root_key.is_null() || chain_key.is_null() || local_fp.is_null() || remote_fp.is_null() || ek_pk.is_null() || ek_sk.is_null() { return SolitonError::NullPointer as i32; } check_len!(root_key_len, 32); check_len!(chain_key_len, 32); check_len!(local_fp_len, 32); check_len!(remote_fp_len, 32); // Exact-size guards: validate before .to_vec() to prevent allocation // amplification from oversized caller-provided lengths. check_len!(ek_pk_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); check_len!(ek_sk_len, soliton::constants::XWING_SECRET_KEY_SIZE); // SAFETY: root_key is non-null (checked above) and validated to be 32 bytes. let mut rk = unsafe { *(root_key as *const [u8; 32]) }; // SAFETY: chain_key is non-null (checked above) and validated to be 32 bytes. let mut ck = unsafe { *(chain_key as *const [u8; 32]) }; let lfp = unsafe { *(local_fp as *const [u8; 32]) }; let rfp = unsafe { *(remote_fp as *const [u8; 32]) }; let pk_bytes = unsafe { slice::from_raw_parts(ek_pk, ek_pk_len) }; let sk_bytes = unsafe { slice::from_raw_parts(ek_sk, ek_sk_len) }; // `try_capi!` cannot be used here: `rk` and `ck` are [u8; 32] (Copy) and // each error path must explicitly zeroize them before returning. let pk = match xwing::PublicKey::from_bytes(pk_bytes.to_vec()) { Ok(pk) => pk, Err(e) => { rk.zeroize(); ck.zeroize(); return error_to_code(e); } }; let sk = match xwing::SecretKey::from_bytes(sk_bytes.to_vec()) { Ok(sk) => sk, Err(e) => { rk.zeroize(); ck.zeroize(); return error_to_code(e); } }; let state = match soliton::ratchet::RatchetState::init_alice(rk, ck, lfp, rfp, pk, sk) { Ok(s) => s, Err(e) => { rk.zeroize(); ck.zeroize(); return error_to_code(e); } }; // [u8; 32] is Copy — init_alice received bitwise copies, so the locals // must be explicitly zeroized to avoid leaving key material on the stack. rk.zeroize(); ck.zeroize(); unsafe { *out = Box::into_raw(Box::new(SolitonRatchet { magic: MAGIC_RATCHET, inner: state, in_use: AtomicBool::new(false), })) }; 0 } /// Initialize ratchet state for Bob (responder). /// /// `root_key`: 32 bytes. /// `chain_key`: 32 bytes (initial epoch key for counter-mode derivation). /// `local_fp`: local identity fingerprint (32 bytes). /// `remote_fp`: remote identity fingerprint (32 bytes). /// `peer_ek` / `peer_ek_len`: Alice's ephemeral X-Wing public key. /// `out`: receives an opaque `SolitonRatchet*` (caller frees with `soliton_ratchet_free`). /// /// # Security /// /// `root_key` and `chain_key` are **copied** into the ratchet state. The caller /// should zeroize its copies immediately after this call (e.g., `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_init_bob( root_key: *const u8, root_key_len: usize, chain_key: *const u8, chain_key_len: usize, local_fp: *const u8, local_fp_len: usize, remote_fp: *const u8, remote_fp_len: usize, peer_ek: *const u8, peer_ek_len: usize, out: *mut *mut SolitonRatchet, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if root_key.is_null() || chain_key.is_null() || local_fp.is_null() || remote_fp.is_null() || peer_ek.is_null() { return SolitonError::NullPointer as i32; } check_len!(root_key_len, 32); check_len!(chain_key_len, 32); check_len!(local_fp_len, 32); check_len!(remote_fp_len, 32); check_len!(peer_ek_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); // SAFETY: root_key is non-null (checked above) and validated to be 32 bytes. let mut rk = unsafe { *(root_key as *const [u8; 32]) }; // SAFETY: chain_key is non-null (checked above) and validated to be 32 bytes. let mut ck = unsafe { *(chain_key as *const [u8; 32]) }; let lfp = unsafe { *(local_fp as *const [u8; 32]) }; let rfp = unsafe { *(remote_fp as *const [u8; 32]) }; let pk_bytes = unsafe { slice::from_raw_parts(peer_ek, peer_ek_len) }; // `try_capi!` cannot be used here: `rk` and `ck` are [u8; 32] (Copy) and // the error path must explicitly zeroize them before returning. let pk = match xwing::PublicKey::from_bytes(pk_bytes.to_vec()) { Ok(pk) => pk, Err(e) => { rk.zeroize(); ck.zeroize(); return error_to_code(e); } }; let state = match soliton::ratchet::RatchetState::init_bob(rk, ck, lfp, rfp, pk) { Ok(s) => s, Err(e) => { rk.zeroize(); ck.zeroize(); return error_to_code(e); } }; // [u8; 32] is Copy — init_bob received bitwise copies, so the locals // must be explicitly zeroized to avoid leaving key material on the stack. rk.zeroize(); ck.zeroize(); unsafe { *out = Box::into_raw(Box::new(SolitonRatchet { magic: MAGIC_RATCHET, inner: state, in_use: AtomicBool::new(false), })) }; 0 } /// Encrypt a message using the ratchet. /// /// `ratchet`: opaque ratchet state (fingerprints bound at init time). /// `plaintext` / `plaintext_len`: message to encrypt. /// `out`: receives the encrypted message (caller frees components with `soliton_encrypted_message_free`). /// /// Nonce construction: the ratchet derives a 24-byte XChaCha20-Poly1305 nonce /// from the message counter (`n`). See Specification.md §6.5 for the construction. /// AAD is built internally from the sender/recipient fingerprints (bound at /// init time) and the ratchet header fields. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any pointer argument is null. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, or session is dead /// (post-reset or all-zero root key). /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): ratchet handle is in use by another call. /// - `SOLITON_ERR_CHAIN_EXHAUSTED` (-15): send counter reached `u32::MAX`. /// - `SOLITON_ERR_AEAD` (-4): AEAD encryption failed (structurally /// infallible for valid inputs; defense-in-depth). On this error, the ratchet /// state is permanently reset (all keys zeroized). The handle remains valid /// for `soliton_ratchet_free` but all subsequent encrypt/decrypt calls will /// return `SOLITON_ERR_INVALID_DATA`. The caller must re-establish the session /// via KEX. /// /// On error, `*out` is zeroed — `soliton_encrypted_message_free` is safe to call /// (it will be a no-op on the zeroed struct). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_encrypt( ratchet: *mut SolitonRatchet, plaintext: *const u8, plaintext_len: usize, out: *mut SolitonEncryptedMessage, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } // Zero output before remaining checks so that all error paths (including // the ratchet-null check below) leave *out in a safe, freeable state. unsafe { std::ptr::write_bytes( out as *mut u8, 0, std::mem::size_of::(), ) }; if ratchet.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*ratchet }, MAGIC_RATCHET); if plaintext.is_null() && plaintext_len != 0 { return SolitonError::NullPointer as i32; } // Upper bound: symmetric with the 256 MiB cap on ratchet_decrypt's // ciphertext_len. No legitimate ratchet message approaches this size. const MAX_PLAINTEXT_LEN: usize = 256 * 1024 * 1024; if plaintext_len > MAX_PLAINTEXT_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: ratchet is non-null (checked at function entry). let state = unsafe { &mut (*ratchet).inner }; let pt = if plaintext_len == 0 { &[] } else { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } }; match state.encrypt(pt) { Ok(msg) => { // SolitonBuf::from_vec allocations below are sequenced before the // final `*out = ...` assignment. If any allocation panicked before the // write, the already-allocated buffers would leak. Under the // `panic = "abort"` profile the process terminates, so no leak occurs. let header = SolitonRatchetHeader { ratchet_pk: SolitonBuf::from_vec(msg.header.ratchet_pk.as_bytes().to_vec()), kem_ct: match msg.header.kem_ct { Some(ct) => SolitonBuf::from_vec(ct.as_bytes().to_vec()), None => SolitonBuf::empty(), }, n: msg.header.n, pn: msg.header.pn, }; unsafe { *out = SolitonEncryptedMessage { header, ciphertext: SolitonBuf::from_vec(msg.ciphertext), }; } 0 } Err(e) => error_to_code(e), } } /// Decrypt a message using the ratchet. /// /// Parameters correspond to fields of `SolitonEncryptedMessage` returned by /// `soliton_ratchet_encrypt`: /// /// - `ratchet_pk` / `ratchet_pk_len` ← `msg.header.ratchet_pk` /// - `kem_ct` / `kem_ct_len` ← `msg.header.kem_ct` (pass null + 0 if `.ptr` is null) /// - `n` ← `msg.header.n` /// - `pn` ← `msg.header.pn` /// - `ciphertext` / `ciphertext_len` ← `msg.ciphertext` /// /// `ratchet`: opaque ratchet state (fingerprints bound at init time). /// `plaintext_out`: receives the decrypted message (caller frees). /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `ratchet_pk_len` is not exactly /// `SOLITON_XWING_PK_SIZE` (1216), or `kem_ct_len` is not exactly /// `SOLITON_XWING_CT_SIZE` (1120) when `kem_ct` is non-null. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): ratchet handle is in use by another call. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, session is dead /// (post-reset or all-zero root key), or message is structurally invalid. /// - `SOLITON_ERR_AEAD` (-4): AEAD decryption failed (invalid /// ciphertext, wrong key, or tampered message). /// - `SOLITON_ERR_CHAIN_EXHAUSTED` (-15): message counter `n` is `u32::MAX`, /// or `recv_seen` set is full (MAX_RECV_SEEN). /// - `SOLITON_ERR_DUPLICATE` (-7): message already decrypted (duplicate counter). /// - `SOLITON_ERR_DECAPSULATION` (-2): KEM decapsulation failed during /// a new-epoch ratchet step. /// /// On **all** errors, the ratchet state performs a **full rollback** to its /// pre-call state — the session remains functional and subsequent /// encrypt/decrypt calls will succeed normally. This differs from `encrypt`, /// where AEAD failure is catastrophic and triggers permanent reset. /// /// # Security /// /// The returned buffer contains sensitive plaintext. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_decrypt( ratchet: *mut SolitonRatchet, ratchet_pk: *const u8, ratchet_pk_len: usize, kem_ct: *const u8, kem_ct_len: usize, n: u32, pn: u32, ciphertext: *const u8, ciphertext_len: usize, plaintext_out: *mut SolitonBuf, ) -> i32 { if plaintext_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( plaintext_out as *mut u8, 0, std::mem::size_of::(), ) }; if ratchet.is_null() || ratchet_pk.is_null() || ciphertext.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*ratchet }, MAGIC_RATCHET); if ciphertext_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound: reject ciphertexts larger than 256 MiB to prevent // allocation amplification via a malicious caller-provided length. // No legitimate ratchet message approaches this size. const MAX_CIPHERTEXT_LEN: usize = 256 * 1024 * 1024; if ciphertext_len > MAX_CIPHERTEXT_LEN { return SolitonError::InvalidLength as i32; } // Exact-size guard: ratchet public key is always XWING_PUBLIC_KEY_SIZE. // Validates before .to_vec() to prevent allocation amplification from // oversized caller-provided lengths. if ratchet_pk_len != soliton::constants::XWING_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } // SAFETY: ratchet is non-null (checked at function entry). let state = unsafe { &mut (*ratchet).inner }; let rpk = unsafe { slice::from_raw_parts(ratchet_pk, ratchet_pk_len) }; // Co-presence guard: kem_ct is optional, but null+nonzero or non-null+zero // are invalid — they indicate a caller logic bug. if (kem_ct.is_null() && kem_ct_len != 0) || (!kem_ct.is_null() && kem_ct_len == 0) { return SolitonError::NullPointer as i32; } // Exact-size guard for KEM ciphertext (when present). if !kem_ct.is_null() && kem_ct_len != soliton::constants::XWING_CIPHERTEXT_SIZE { return SolitonError::InvalidLength as i32; } let ct_opt = if kem_ct.is_null() { None } else { let ct_bytes = unsafe { slice::from_raw_parts(kem_ct, kem_ct_len) }; match xwing::Ciphertext::from_bytes(ct_bytes.to_vec()) { Ok(ct) => Some(ct), Err(e) => return error_to_code(e), } }; let ct = unsafe { slice::from_raw_parts(ciphertext, ciphertext_len) }; let rpk_validated = try_capi!(xwing::PublicKey::from_bytes(rpk.to_vec())); let header = soliton::ratchet::RatchetHeader { ratchet_pk: rpk_validated, kem_ct: ct_opt, n, pn, }; match state.decrypt(&header, ct) { Ok(mut plaintext) => { unsafe { *plaintext_out = SolitonBuf::from_vec(std::mem::take(&mut *plaintext)) }; 0 } Err(e) => error_to_code(e), } } /// Encrypt the first message of a session (session init payload). /// /// `chain_key` / `chain_key_len`: the `initial_chain_key` from /// [`SolitonInitiatedSession`] (exactly 32 bytes). /// `plaintext` / `plaintext_len`: message to encrypt. /// `aad` / `aad_len`: additional authenticated data. Construct using /// `soliton_kex_build_first_message_aad` with the sender/recipient /// fingerprints and the encoded `SessionInit` from step 1. /// `payload_out`: receives nonce || ciphertext (caller frees). /// `ratchet_init_key_out` / `ratchet_init_key_out_len`: receives the derived /// chain key for `soliton_ratchet_init_alice` (exactly 32 bytes). /// /// # Caller Obligations — Key Protocol Sequence /// /// The KEX→ratchet handoff requires three steps in order: /// /// 1. `soliton_kex_initiate` → produces `SolitonInitiatedSession` with /// `initial_chain_key` and `root_key`. /// 2. **This function** → takes `initial_chain_key` as input, outputs /// `ratchet_init_key_out` (the same epoch key, passed through unchanged /// under counter-mode). /// 3. `soliton_ratchet_init_alice` → takes `root_key` from step 1 and /// `ratchet_init_key_out` from step 2 as `chain_key`. /// /// **Note:** Under counter-mode, `ratchet_init_key_out` equals /// `initial_chain_key` (the epoch key passes through unchanged). Step 2 /// must still be called — it encrypts the first message and the API /// enforces the correct ordering via ownership semantics on the Rust side. /// /// # Security /// /// `ratchet_init_key_out` contains sensitive chain-key material. Zeroize it /// (e.g., `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows) immediately after passing it to /// `soliton_ratchet_init_alice`. Do not store it beyond that call. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_encrypt_first( chain_key: *const u8, chain_key_len: usize, plaintext: *const u8, plaintext_len: usize, aad: *const u8, aad_len: usize, payload_out: *mut SolitonBuf, ratchet_init_key_out: *mut u8, ratchet_init_key_out_len: usize, ) -> i32 { if payload_out.is_null() || ratchet_init_key_out.is_null() { return SolitonError::NullPointer as i32; } // Zero outputs immediately after null-check so every subsequent early // return (check_len!, NullPointer on chain_key, etc.) leaves clean buffers. // payload_out is zeroed unconditionally; ratchet_init_key_out is zeroed for // min(ratchet_init_key_out_len, 32) bytes since we haven't validated the // length yet. unsafe { std::ptr::write_bytes(payload_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(ratchet_init_key_out, 0, ratchet_init_key_out_len.min(32)); } check_len!(ratchet_init_key_out_len, 32); if chain_key.is_null() { return SolitonError::NullPointer as i32; } check_len!(chain_key_len, 32); // Copy chain_key into a local BEFORE re-zeroing output buffers — if the // caller aliases chain_key and ratchet_init_key_out (output replaces input), // the zero-fill would destroy the input. let mut ck_local = zeroize::Zeroizing::new([0u8; 32]); unsafe { std::ptr::copy_nonoverlapping(chain_key, ck_local.as_mut_ptr(), 32) }; // Re-zero ratchet_init_key_out now that we know it's exactly 32 bytes. // payload_out was already zeroed above and hasn't been written to. unsafe { std::ptr::write_bytes(ratchet_init_key_out, 0, 32); } if (plaintext.is_null() && plaintext_len != 0) || (aad.is_null() && aad_len != 0) { return SolitonError::NullPointer as i32; } // Upper bound: cap plaintext and AAD at 256 MiB to prevent allocation // amplification from oversized caller-provided lengths. const MAX_PLAINTEXT_LEN: usize = 256 * 1024 * 1024; if plaintext_len > MAX_PLAINTEXT_LEN || aad_len > MAX_PLAINTEXT_LEN { return SolitonError::InvalidLength as i32; } let pt = if plaintext_len == 0 { &[] } else { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } }; let ad = if aad_len == 0 { &[] } else { unsafe { slice::from_raw_parts(aad, aad_len) } }; match soliton::ratchet::RatchetState::encrypt_first_message(ck_local, pt, ad) { Ok((payload, next_ck)) => { unsafe { *payload_out = SolitonBuf::from_vec(payload); std::ptr::copy_nonoverlapping(next_ck.as_ptr(), ratchet_init_key_out, 32); } 0 } Err(e) => error_to_code(e), } } /// Decrypt the first message of a session. /// /// `chain_key` / `chain_key_len`: 32 bytes exactly. /// `encrypted_payload` / `encrypted_payload_len`: nonce || ciphertext. /// `aad` / `aad_len`: additional authenticated data. /// `plaintext_out`: receives the decrypted message (caller frees). /// `ratchet_init_key_out` / `ratchet_init_key_out_len`: receives the chain key /// for `soliton_ratchet_init_bob` (exactly 32 bytes). Do NOT use the /// `chain_key` input for ratchet init. /// /// # Security /// /// `ratchet_init_key_out` contains sensitive chain-key material. Zeroize it /// (e.g., `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows) immediately after passing it to /// `soliton_ratchet_init_bob`. Do not store it beyond that call. /// /// The returned buffer contains sensitive plaintext. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_decrypt_first( chain_key: *const u8, chain_key_len: usize, encrypted_payload: *const u8, encrypted_payload_len: usize, aad: *const u8, aad_len: usize, plaintext_out: *mut SolitonBuf, ratchet_init_key_out: *mut u8, ratchet_init_key_out_len: usize, ) -> i32 { if plaintext_out.is_null() || ratchet_init_key_out.is_null() { return SolitonError::NullPointer as i32; } // Zero outputs immediately after null-check (same pattern as encrypt_first). unsafe { std::ptr::write_bytes( plaintext_out as *mut u8, 0, std::mem::size_of::(), ); std::ptr::write_bytes(ratchet_init_key_out, 0, ratchet_init_key_out_len.min(32)); } check_len!(ratchet_init_key_out_len, 32); if chain_key.is_null() || encrypted_payload.is_null() { return SolitonError::NullPointer as i32; } check_len!(chain_key_len, 32); // Copy chain_key into a local BEFORE re-zeroing output buffers — prevents // aliased input corruption (same rationale as encrypt_first). let mut ck_local = zeroize::Zeroizing::new([0u8; 32]); unsafe { std::ptr::copy_nonoverlapping(chain_key, ck_local.as_mut_ptr(), 32) }; unsafe { std::ptr::write_bytes(ratchet_init_key_out, 0, 32); } if aad.is_null() && aad_len != 0 { return SolitonError::NullPointer as i32; } if encrypted_payload_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound: cap ciphertext and AAD at 256 MiB to prevent allocation // amplification from oversized caller-provided lengths. const MAX_CIPHERTEXT_LEN: usize = 256 * 1024 * 1024; if encrypted_payload_len > MAX_CIPHERTEXT_LEN || aad_len > MAX_CIPHERTEXT_LEN { return SolitonError::InvalidLength as i32; } let payload = unsafe { slice::from_raw_parts(encrypted_payload, encrypted_payload_len) }; let ad = if aad_len == 0 { &[] } else { unsafe { slice::from_raw_parts(aad, aad_len) } }; match soliton::ratchet::RatchetState::decrypt_first_message(ck_local, payload, ad) { Ok((mut plaintext, next_ck)) => { unsafe { *plaintext_out = SolitonBuf::from_vec(std::mem::take(&mut *plaintext)); std::ptr::copy_nonoverlapping(next_ck.as_ptr(), ratchet_init_key_out, 32); } 0 } Err(e) => error_to_code(e), } } /// Reset a ratchet session, zeroizing all state. /// /// `ratchet`: opaque ratchet state to reset in-place. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_reset(ratchet: *mut SolitonRatchet) -> i32 { if ratchet.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*ratchet }, MAGIC_RATCHET); unsafe { (*ratchet).inner.reset() }; 0 } /// Free a ratchet state object, zeroizing all contained key material. /// /// `ratchet`: pointer to the caller's `SolitonRatchet*` variable. After /// freeing, the caller's pointer is set to NULL, making double-free a no-op. /// Null outer pointer and null inner pointer are both no-ops (returns 0). /// /// Returns 0 on success, `SOLITON_ERR_CONCURRENT_ACCESS` (-18) if the handle /// is currently in use by another operation. On concurrent-access failure, the /// handle is NOT freed — the caller must retry after the in-flight operation /// completes. GC finalizers that run exactly once must ensure no operations /// are in flight before calling free. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_free(ratchet: *mut *mut SolitonRatchet) -> i32 { if ratchet.is_null() { return 0; } let inner = unsafe { *ratchet }; if inner.is_null() { return 0; } if unsafe { &*inner }.magic != MAGIC_RATCHET { return SolitonError::InvalidData as i32; } // Acquire exclusive access via CAS before freeing. If another thread // holds the guard (concurrent encrypt/decrypt/to_bytes), we return an // error so the caller can retry. We cannot use ReentrancyGuard (RAII) // here because the guard's Drop would write to freed memory after // Box::from_raw drops the handle. if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } // SAFETY: inner was allocated via Box::into_raw in init_alice/init_bob/from_bytes. // We hold exclusive access (in_use = true). RatchetState::Drop zeroizes // all key material before deallocation. The in_use flag is freed with the // allocation — no release store needed. unsafe { drop(Box::from_raw(inner)) }; // Null the caller's pointer — subsequent free calls are no-ops. unsafe { *ratchet = std::ptr::null_mut() }; 0 } /// Free an encrypted message's internal buffers — does NOT free the /// `SolitonEncryptedMessage` struct itself (it is a value type written /// into caller-provided storage, not heap-allocated by soliton). /// /// `msg`: encrypted message pointer (null-safe; no-op if null). /// /// Frees each buffer (ratchet_pk, kem_ct, ciphertext) via `soliton_buf_free` /// (which zeroizes before freeing). #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_encrypted_message_free(msg: *mut SolitonEncryptedMessage) { if !msg.is_null() { unsafe { let m = &mut *msg; soliton_buf_free(&mut m.header.ratchet_pk); soliton_buf_free(&mut m.header.kem_ct); soliton_buf_free(&mut m.ciphertext); } } } // ═══════════════════════════════════════════════════════════════════════ // Storage: encrypt/decrypt blobs // ═══════════════════════════════════════════════════════════════════════ /// Opaque storage keyring. /// /// # Undefined Behavior /// /// **Do not duplicate handles.** Copying the pointer and using both copies /// produces undefined behavior: freeing one alias invalidates the other /// (use-after-free). The reentrancy guard only prevents *concurrent* access, /// not *sequential* use from aliased pointers to freed memory. pub struct SolitonKeyRing { magic: u32, inner: soliton::storage::StorageKeyRing, in_use: AtomicBool, } /// Create a storage keyring with one initial key. /// /// `key`: 32-byte encryption key (must not be all-zero). /// `version`: key version (1-255). Version 0 returns /// `SOLITON_ERR_VERSION`. /// `out`: receives the opaque `SolitonKeyRing*` (caller frees with `soliton_keyring_free`). /// /// Returns 0 on success, negative error code on failure. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_keyring_new( key: *const u8, key_len: usize, version: u8, out: *mut *mut SolitonKeyRing, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if key.is_null() { return SolitonError::NullPointer as i32; } check_len!(key_len, 32); // SAFETY: key is non-null (checked above) and validated to be 32 bytes. let mut key_bytes = unsafe { *(key as *const [u8; 32]) }; let result = soliton::storage::StorageKey::new(version, key_bytes); // [u8; 32] is Copy — StorageKey::new received a bitwise copy, so the local // must be explicitly zeroized before any early return (try_capi may exit). key_bytes.zeroize(); let storage_key = try_capi!(result); match soliton::storage::StorageKeyRing::new(storage_key) { Ok(keyring) => { unsafe { *out = Box::into_raw(Box::new(SolitonKeyRing { magic: MAGIC_KEYRING, inner: keyring, in_use: AtomicBool::new(false), })) }; 0 } Err(e) => error_to_code(e), } } /// Add a key to the storage keyring. /// /// `key`: 32-byte encryption key. /// `version`: key version (1-255). Version 0 returns `SOLITON_ERR_VERSION`. /// `make_active`: if non-zero, this key becomes the active key for new writes. /// /// If `version` matches the current active version and `make_active` is 0, /// returns `SOLITON_ERR_INVALID_DATA` — replacing the active key's material /// without re-activating would silently invalidate existing blobs. /// /// Returns 0 on success, negative error code on failure. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_keyring_add_key( keyring: *mut SolitonKeyRing, key: *const u8, key_len: usize, version: u8, make_active: i32, ) -> i32 { if keyring.is_null() || key.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); check_len!(key_len, 32); // SAFETY: key is non-null (checked above) and validated to be 32 bytes. let mut key_bytes = unsafe { *(key as *const [u8; 32]) }; let result = soliton::storage::StorageKey::new(version, key_bytes); // [u8; 32] is Copy — StorageKey::new received a bitwise copy, so the local // must be explicitly zeroized before any early return (try_capi may exit). key_bytes.zeroize(); let storage_key = try_capi!(result); // SAFETY: keyring is non-null (checked at function entry). match unsafe { (*keyring).inner.add_key(storage_key, make_active != 0) } { Ok(_) => 0, Err(e) => error_to_code(e), } } /// Remove a key version from the storage keyring. /// /// `keyring`: opaque storage keyring. /// `version`: key version to remove. Version 0 returns `SOLITON_ERR_VERSION`. /// Removing the active version returns `SOLITON_ERR_INVALID_DATA` — callers must /// set a new active key via `soliton_keyring_add_key(make_active=1)` before removing /// the old one. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_keyring_remove_key( keyring: *mut SolitonKeyRing, version: u8, ) -> i32 { if keyring.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); match unsafe { (*keyring).inner.remove_key(version) } { Ok(_) => 0, Err(e) => error_to_code(e), } } /// Free a storage keyring, zeroizing all contained key material. /// /// `keyring`: pointer to the caller's `SolitonKeyRing*` variable. After /// freeing, the caller's pointer is set to NULL, making double-free a no-op. /// Null outer pointer and null inner pointer are both no-ops (returns 0). /// /// Returns 0 on success, `SOLITON_ERR_CONCURRENT_ACCESS` (-18) if the handle /// is currently in use. See `soliton_ratchet_free` for retry semantics. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_keyring_free(keyring: *mut *mut SolitonKeyRing) -> i32 { if keyring.is_null() { return 0; } let inner = unsafe { *keyring }; if inner.is_null() { return 0; } if unsafe { &*inner }.magic != MAGIC_KEYRING { return SolitonError::InvalidData as i32; } // See soliton_ratchet_free for CAS rationale (cannot use RAII guard // because Drop would write to freed memory). if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } // SAFETY: inner was allocated via Box::into_raw in soliton_keyring_new. // StorageKeyRing::Drop zeroizes all key material before deallocation. unsafe { drop(Box::from_raw(inner)) }; // Null the caller's pointer — subsequent free calls are no-ops. unsafe { *keyring = std::ptr::null_mut() }; 0 } /// Encrypt data for storage. /// /// `keyring`: storage keyring (uses the active key). /// `plaintext` / `plaintext_len`: data to encrypt. /// `channel_id`: null-terminated channel ID string. /// `segment_id`: null-terminated segment ID string. /// `compress`: non-zero to enable zstd compression. /// `blob_out`: receives the encrypted blob (caller frees with `soliton_buf_free`). /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `plaintext_len` exceeds 256 MiB. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, `channel_id` or /// `segment_id` is not valid UTF-8, or keyring has no active key. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): keyring handle is in use by another call. /// - `SOLITON_ERR_AEAD` (-4): AEAD encryption failed (defense-in-depth). /// /// # Security /// /// When compression is enabled, zstd internal buffers may retain plaintext /// in freed heap memory. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_storage_encrypt( keyring: *const SolitonKeyRing, plaintext: *const u8, plaintext_len: usize, channel_id: *const c_char, segment_id: *const c_char, compress: i32, blob_out: *mut SolitonBuf, ) -> i32 { if blob_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(blob_out as *mut u8, 0, std::mem::size_of::()) }; if keyring.is_null() || channel_id.is_null() || segment_id.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); if plaintext.is_null() && plaintext_len != 0 { return SolitonError::NullPointer as i32; } const MAX_PLAINTEXT_LEN: usize = 256 * 1024 * 1024; if plaintext_len > MAX_PLAINTEXT_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: keyring is non-null (checked at function entry). let kr = unsafe { &(*keyring).inner }; let key = match kr.active_key() { Some(k) => k, // InvalidData: from the caller's perspective the keyring has no usable // write key, regardless of how it reached that state. None => return SolitonError::InvalidData as i32, }; let pt = if plaintext_len == 0 { &[] } else { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } }; let ch_id = match unsafe { cstr_to_str(channel_id) } { Ok(s) => s, Err(code) => return code, }; let seg_id = match unsafe { cstr_to_str(segment_id) } { Ok(s) => s, Err(code) => return code, }; match soliton::storage::encrypt_blob(key, pt, ch_id, seg_id, compress != 0) { Ok(blob) => { unsafe { *blob_out = SolitonBuf::from_vec(blob) }; 0 } Err(e) => error_to_code(e), } } /// Decrypt a storage blob. /// /// `keyring`: storage keyring (looks up key by version byte in blob). /// `blob` / `blob_len`: encrypted blob. /// `channel_id`: null-terminated channel ID string. /// `segment_id`: null-terminated segment ID string. /// `plaintext_out`: receives the decrypted data (caller frees with `soliton_buf_free`). /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `blob_len` is 0 or exceeds 256 MiB. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): keyring handle is in use by another call. /// - `SOLITON_ERR_AEAD` (-4): any decryption or post-decryption failure. All /// distinct internal errors (AEAD tag failure, key version not found, /// decompression failure, unsupported flags, structurally invalid blob) are /// collapsed to this single code to prevent error-oracle attacks. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, or `channel_id` / /// `segment_id` is not valid UTF-8. /// /// # Security /// /// The returned buffer contains sensitive plaintext. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_storage_decrypt( keyring: *const SolitonKeyRing, blob: *const u8, blob_len: usize, channel_id: *const c_char, segment_id: *const c_char, plaintext_out: *mut SolitonBuf, ) -> i32 { if plaintext_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( plaintext_out as *mut u8, 0, std::mem::size_of::(), ) }; if keyring.is_null() || blob.is_null() || channel_id.is_null() || segment_id.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); if blob_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound: cap blob at 256 MiB to prevent allocation amplification. const MAX_BLOB_LEN: usize = 256 * 1024 * 1024; if blob_len > MAX_BLOB_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: keyring is non-null (checked at function entry). let kr = unsafe { &(*keyring).inner }; let blob_data = unsafe { slice::from_raw_parts(blob, blob_len) }; let ch_id = match unsafe { cstr_to_str(channel_id) } { Ok(s) => s, Err(code) => return code, }; let seg_id = match unsafe { cstr_to_str(segment_id) } { Ok(s) => s, Err(code) => return code, }; match soliton::storage::decrypt_blob(kr, blob_data, ch_id, seg_id) { Ok(mut plaintext) => { unsafe { *plaintext_out = SolitonBuf::from_vec(std::mem::take(&mut *plaintext)) }; 0 } Err(e) => error_to_code(e), } } /// Encrypt data for DM queue storage (§11.4.2). /// /// `keyring`: storage keyring (uses the active key). /// `plaintext` / `plaintext_len`: data to encrypt. /// `recipient_fp` / `recipient_fp_len`: recipient identity fingerprint /// (exactly 32 bytes). /// `batch_id`: null-terminated batch ID string. /// `compress`: non-zero to enable zstd compression. /// `blob_out`: receives the encrypted blob (caller frees with `soliton_buf_free`). /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, `batch_id` is not /// valid UTF-8, or keyring has no active key. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `plaintext_len` exceeds 256 MiB, or /// `recipient_fp_len` is not 32. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): keyring handle is in use by another call. /// - `SOLITON_ERR_AEAD` (-4): AEAD encryption failed (defense-in-depth). /// /// # Security /// /// The recipient fingerprint is bound into the AAD — a blob encrypted for /// one recipient cannot be decrypted with a different fingerprint. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_dm_queue_encrypt( keyring: *const SolitonKeyRing, plaintext: *const u8, plaintext_len: usize, recipient_fp: *const u8, recipient_fp_len: usize, batch_id: *const c_char, compress: i32, blob_out: *mut SolitonBuf, ) -> i32 { if blob_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(blob_out as *mut u8, 0, std::mem::size_of::()) }; if keyring.is_null() || recipient_fp.is_null() || batch_id.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); if plaintext.is_null() && plaintext_len != 0 { return SolitonError::NullPointer as i32; } check_len!(recipient_fp_len, 32); const MAX_PLAINTEXT_LEN: usize = 256 * 1024 * 1024; if plaintext_len > MAX_PLAINTEXT_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: keyring is non-null (checked at function entry). let kr = unsafe { &(*keyring).inner }; let key = match kr.active_key() { Some(k) => k, None => return SolitonError::InvalidData as i32, }; let pt = if plaintext_len == 0 { &[] } else { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } }; // SAFETY: recipient_fp is non-null (checked above) and validated to be 32 bytes. let fp: &[u8; 32] = unsafe { &*(recipient_fp as *const [u8; 32]) }; let bid = match unsafe { cstr_to_str(batch_id) } { Ok(s) => s, Err(code) => return code, }; match soliton::storage::encrypt_dm_queue_blob(key, pt, fp, bid, compress != 0) { Ok(blob) => { unsafe { *blob_out = SolitonBuf::from_vec(blob) }; 0 } Err(e) => error_to_code(e), } } /// Decrypt a DM queue storage blob (§11.4.2). /// /// `keyring`: storage keyring (looks up key by version byte in blob). /// `blob` / `blob_len`: encrypted blob. /// `recipient_fp` / `recipient_fp_len`: recipient identity fingerprint /// (exactly 32 bytes). /// `batch_id`: null-terminated batch ID string. /// `plaintext_out`: receives the decrypted data (caller frees with `soliton_buf_free`). /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `blob_len` is 0 or exceeds 256 MiB, /// or `recipient_fp_len` is not 32. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): keyring handle is in use by another call. /// - `SOLITON_ERR_AEAD` (-4): any decryption or post-decryption failure. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, or `batch_id` /// is not valid UTF-8. /// /// # Security /// /// The returned buffer contains sensitive plaintext. Free it with /// `soliton_buf_free`, NOT `free()`. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_dm_queue_decrypt( keyring: *const SolitonKeyRing, blob: *const u8, blob_len: usize, recipient_fp: *const u8, recipient_fp_len: usize, batch_id: *const c_char, plaintext_out: *mut SolitonBuf, ) -> i32 { if plaintext_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( plaintext_out as *mut u8, 0, std::mem::size_of::(), ) }; if keyring.is_null() || blob.is_null() || recipient_fp.is_null() || batch_id.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keyring }, MAGIC_KEYRING); check_len!(recipient_fp_len, 32); if blob_len == 0 { return SolitonError::InvalidLength as i32; } const MAX_BLOB_LEN: usize = 256 * 1024 * 1024; if blob_len > MAX_BLOB_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: keyring is non-null (checked at function entry). let kr = unsafe { &(*keyring).inner }; let blob_data = unsafe { slice::from_raw_parts(blob, blob_len) }; // SAFETY: recipient_fp is non-null (checked above) and validated to be 32 bytes. let fp: &[u8; 32] = unsafe { &*(recipient_fp as *const [u8; 32]) }; let bid = match unsafe { cstr_to_str(batch_id) } { Ok(s) => s, Err(code) => return code, }; match soliton::storage::decrypt_dm_queue_blob(kr, blob_data, fp, bid) { Ok(mut plaintext) => { unsafe { *plaintext_out = SolitonBuf::from_vec(std::mem::take(&mut *plaintext)) }; 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // Primitives: HMAC-SHA3-256, HKDF-SHA3-256 // ═══════════════════════════════════════════════════════════════════════ /// Compute HMAC-SHA3-256. /// /// `key` / `key_len`: HMAC key. /// `data` / `data_len`: message to authenticate. /// `out` / `out_len`: must be exactly 32 bytes. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_hmac_sha3_256( key: *const u8, key_len: usize, data: *const u8, data_len: usize, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } check_len!(out_len, 32); // Cap input lengths to prevent CPU DoS from untrusted input — // consistent with the 256 MiB cap on AEAD/ratchet/storage functions. const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if key_len > MAX_INPUT_LEN || data_len > MAX_INPUT_LEN { unsafe { std::ptr::write_bytes(out, 0, 32) }; return SolitonError::InvalidLength as i32; } if key.is_null() && key_len != 0 { unsafe { std::ptr::write_bytes(out, 0, 32) }; return SolitonError::NullPointer as i32; } if data.is_null() && data_len != 0 { unsafe { std::ptr::write_bytes(out, 0, 32) }; return SolitonError::NullPointer as i32; } // SAFETY: key is non-null when key_len > 0 (checked above). // HMAC with an empty key is valid (RFC 2104 §2 — key shorter than block // size is zero-padded). // Read key and data into slices and compute BEFORE writing to out — if // the caller aliases key and out (HMAC key update in-place), zeroing out // first would destroy the key input. let k = if key_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(key, key_len) } }; // SAFETY: data is non-null when data_len > 0 (checked above). let d = if data_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(data, data_len) } }; let mut raw_mac = soliton::primitives::hmac::hmac_sha3_256(k, d); // HMAC output may be used as derived key material by callers (e.g., chain // key derivation), so zeroize defensively. let mac = zeroize::Zeroizing::new(raw_mac); // [u8; 32] is Copy — Zeroizing::new() received a bitwise copy, so the // original stack value must be explicitly zeroized. raw_mac.zeroize(); unsafe { std::ptr::copy_nonoverlapping(mac.as_ptr(), out, 32) }; 0 } /// Constant-time comparison of two 32-byte HMAC tags. /// /// `tag_a`: pointer to 32-byte computed HMAC tag. /// `tag_b`: pointer to 32-byte expected HMAC tag. /// /// # Security /// /// Uses `subtle::ConstantTimeEq` — execution time does not depend on which /// bytes differ, preventing timing side-channels. Callers should use this /// instead of `memcmp` to avoid leaking information about the tag value. /// /// Returns 0 if the tags match, `SOLITON_ERR_VERIFICATION` if they differ, /// `SOLITON_ERR_NULL_POINTER` if either pointer is null. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_hmac_sha3_256_verify( tag_a: *const u8, tag_a_len: usize, tag_b: *const u8, tag_b_len: usize, ) -> i32 { if tag_a.is_null() || tag_b.is_null() { return SolitonError::NullPointer as i32; } check_len!(tag_a_len, 32); check_len!(tag_b_len, 32); // SAFETY: both pointers are non-null (checked above) and validated to // be 32 bytes. let a = unsafe { &*(tag_a as *const [u8; 32]) }; let b = unsafe { &*(tag_b as *const [u8; 32]) }; if soliton::primitives::hmac::hmac_sha3_256_verify_raw(a, b) { 0 } else { SolitonError::VerificationFailed as i32 } } /// Compute HKDF-SHA3-256 (extract-and-expand). /// /// `salt` / `salt_len`: HKDF salt. /// `ikm` / `ikm_len`: input keying material. /// `info` / `info_len`: context and application-specific info. /// `out` / `out_len`: output buffer (1-8160 bytes; `out_len == 0` returns /// `SOLITON_ERR_INVALID_LENGTH`). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_hkdf_sha3_256( salt: *const u8, salt_len: usize, ikm: *const u8, ikm_len: usize, info: *const u8, info_len: usize, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } // RFC 5869 §2.3: HKDF-Expand output is limited to 255 * HashLen bytes. // SHA3-256 HashLen = 32, so max = 255 * 32 = 8160 bytes. // Validate BEFORE zeroing: if out_len is wildly oversized, we cannot // trust it as the actual buffer size, so we return without touching it. if out_len == 0 || out_len > 255 * 32 { return SolitonError::InvalidLength as i32; } // Cap input lengths to prevent CPU DoS from untrusted input — // consistent with the 256 MiB cap on AEAD/ratchet/storage functions. const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if salt_len > MAX_INPUT_LEN || ikm_len > MAX_INPUT_LEN || info_len > MAX_INPUT_LEN { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } if (salt.is_null() && salt_len != 0) || (ikm.is_null() && ikm_len != 0) || (info.is_null() && info_len != 0) { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::NullPointer as i32; } // SAFETY: each pointer is non-null when its length > 0 (checked above). let s = if salt_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(salt, salt_len) } }; let i = if ikm_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(ikm, ikm_len) } }; let inf = if info_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(info, info_len) } }; // SAFETY: out is non-null (checked above); out_len validated in range 1..=8160. let o = unsafe { slice::from_raw_parts_mut(out, out_len) }; // Do NOT zero output before calling HKDF — if inputs alias the output // buffer (e.g., salt == out), zeroing would destroy the input. Instead, // zero only on error to leave *out in a defined state. if let Err(e) = soliton::primitives::hkdf::hkdf_sha3_256(s, i, inf, o) { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return error_to_code(e); } 0 } // ═══════════════════════════════════════════════════════════════════════ // Primitives: XChaCha20-Poly1305 // ═══════════════════════════════════════════════════════════════════════ /// Encrypt data with XChaCha20-Poly1305. /// /// `key`: 32-byte encryption key. /// `nonce`: 24-byte nonce. /// `plaintext` / `plaintext_len`: data to encrypt. /// `aad` / `aad_len`: additional authenticated data. /// `ciphertext_out`: receives ciphertext || 16-byte tag (caller frees). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_aead_encrypt( key: *const u8, key_len: usize, nonce: *const u8, nonce_len: usize, plaintext: *const u8, plaintext_len: usize, aad: *const u8, aad_len: usize, ciphertext_out: *mut SolitonBuf, ) -> i32 { if ciphertext_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( ciphertext_out as *mut u8, 0, std::mem::size_of::(), ) }; if key.is_null() || nonce.is_null() { return SolitonError::NullPointer as i32; } check_len!(key_len, 32); check_len!(nonce_len, 24); // plaintext and aad may be null if their lengths are 0. if plaintext.is_null() && plaintext_len != 0 { return SolitonError::NullPointer as i32; } if aad.is_null() && aad_len != 0 { return SolitonError::NullPointer as i32; } // Upper bound: cap plaintext and AAD at 256 MiB to prevent CPU-bound DoS // from oversized caller-provided lengths (Poly1305 processes AAD linearly). const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if plaintext_len > MAX_INPUT_LEN || aad_len > MAX_INPUT_LEN { return SolitonError::InvalidLength as i32; } // SAFETY: key is non-null (checked above) and validated to be 32 bytes. let k = unsafe { &*(key as *const [u8; 32]) }; // SAFETY: nonce is non-null (checked above) and validated to be 24 bytes. let n = unsafe { &*(nonce as *const [u8; 24]) }; let pt = if plaintext_len == 0 { &[] } else { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } }; let ad = if aad_len == 0 { &[] } else { unsafe { slice::from_raw_parts(aad, aad_len) } }; match soliton::primitives::aead::aead_encrypt(k, n, pt, ad) { Ok(ct) => { unsafe { *ciphertext_out = SolitonBuf::from_vec(ct) }; 0 } Err(e) => error_to_code(e), } } /// Decrypt XChaCha20-Poly1305 ciphertext. /// /// `key`: 32-byte encryption key. /// `nonce`: 24-byte nonce. /// `ciphertext` / `ciphertext_len`: ciphertext || 16-byte tag. /// `aad` / `aad_len`: additional authenticated data. /// `plaintext_out`: receives decrypted plaintext (caller frees). /// /// # Security /// /// The returned buffer contains sensitive plaintext. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// /// Returns 0 on success, negative on error (authentication failure). #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_aead_decrypt( key: *const u8, key_len: usize, nonce: *const u8, nonce_len: usize, ciphertext: *const u8, ciphertext_len: usize, aad: *const u8, aad_len: usize, plaintext_out: *mut SolitonBuf, ) -> i32 { if plaintext_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( plaintext_out as *mut u8, 0, std::mem::size_of::(), ) }; if key.is_null() || nonce.is_null() || ciphertext.is_null() { return SolitonError::NullPointer as i32; } check_len!(key_len, 32); check_len!(nonce_len, 24); if ciphertext_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound: cap ciphertext and AAD at 256 MiB to prevent CPU-bound DoS // from oversized caller-provided lengths (Poly1305 processes AAD linearly). const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if ciphertext_len > MAX_INPUT_LEN || aad_len > MAX_INPUT_LEN { return SolitonError::InvalidLength as i32; } if aad.is_null() && aad_len != 0 { return SolitonError::NullPointer as i32; } // SAFETY: key is non-null (checked above) and validated to be 32 bytes. let k = unsafe { &*(key as *const [u8; 32]) }; // SAFETY: nonce is non-null (checked above) and validated to be 24 bytes. let n = unsafe { &*(nonce as *const [u8; 24]) }; let ct = unsafe { slice::from_raw_parts(ciphertext, ciphertext_len) }; let ad = if aad_len == 0 { &[] } else { unsafe { slice::from_raw_parts(aad, aad_len) } }; match soliton::primitives::aead::aead_decrypt(k, n, ct, ad) { Ok(mut pt) => { unsafe { *plaintext_out = SolitonBuf::from_vec(std::mem::take(&mut *pt)) }; 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // Primitives: Argon2id // ═══════════════════════════════════════════════════════════════════════ /// Derive key material from a passphrase using Argon2id (RFC 9106). /// /// `password` / `password_len`: passphrase bytes. May be null with `password_len = 0`. /// `salt` / `salt_len`: random salt; must be at least 8 bytes. /// `m_cost`: memory cost in KiB (recommended 65536 = 64 MiB; maximum 4194304 = 4 GiB). /// Minimum is enforced by the upstream Argon2 library (currently 8 KiB). /// `t_cost`: time cost — number of passes (recommended 3; maximum 256). /// Minimum is enforced by the upstream Argon2 library (currently 1). /// `p_cost`: parallelism — number of lanes (recommended 4; maximum 256). /// Minimum is enforced by the upstream Argon2 library (currently 1). /// `out` / `out_len`: caller-allocated output buffer for derived key material /// (1-4096 bytes). /// /// Presets: use m=65536, t=3, p=4 for locally stored keypairs; m=19456, t=2, p=1 /// for interactive logins. /// /// Returns 0 on success, `SOLITON_ERR_INVALID_DATA` if cost params exceed /// maximum bounds, `SOLITON_ERR_INVALID_LENGTH` if salt or output size is /// out of range, negative on other errors. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_argon2id( password: *const u8, password_len: usize, salt: *const u8, salt_len: usize, m_cost: u32, t_cost: u32, p_cost: u32, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } if out_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound on out_len BEFORE zero-fill: prevent OOB write into a // wildly oversized buffer from untrusted input. Without this ordering, // write_bytes(out, 0, out_len) would overwrite adjacent memory when // out_len exceeds the caller's actual allocation. if out_len > 4096 { return SolitonError::InvalidLength as i32; } // Zero output so error paths leave *out in a defined state. // Placed after null/zero-length/bounds checks but before parameter // validation, so all subsequent error paths return with a zeroed buffer. unsafe { std::ptr::write_bytes(out, 0, out_len) }; // Cap m_cost at 4 GiB (4194304 KiB) to prevent OOM from untrusted input. // Argon2's RFC 9106 allows up to 2^32 - 1 KiB, but no realistic use case // exceeds single-digit GiB. This guard defends against DoS when parameters // originate from network input. if m_cost > 4_194_304 { return SolitonError::InvalidData as i32; } // Cap t_cost (time cost / passes) to prevent CPU DoS from untrusted input. // No realistic use case exceeds single-digit passes. if t_cost > 256 { return SolitonError::InvalidData as i32; } // Cap p_cost (parallelism lanes) to prevent lane-amplification overhead // from untrusted input. No realistic deployment uses >8 lanes. if p_cost > 256 { return SolitonError::InvalidData as i32; } // Cap password and salt lengths to prevent CPU DoS from the initial // Blake2b hash processing extremely large inputs before the memory-hard // phase. 256 MiB is consistent with other CAPI input caps. const MAX_INPUT_LEN: usize = 256 * 1024 * 1024; if password_len > MAX_INPUT_LEN || salt_len > MAX_INPUT_LEN { return SolitonError::InvalidLength as i32; } if (password.is_null() && password_len != 0) || (salt.is_null() && salt_len != 0) { return SolitonError::NullPointer as i32; } // RFC 9106 §3.1: salt must be at least 8 bytes. Enforce at CAPI boundary // to match the doc comment and provide a clear error before the core call. if salt_len < 8 { return SolitonError::InvalidLength as i32; } // SAFETY: each pointer is non-null when its length > 0 (checked above). let pw = if password_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(password, password_len) } }; let s = if salt_len == 0 { &[] as &[u8] } else { unsafe { slice::from_raw_parts(salt, salt_len) } }; // SAFETY: out is non-null (checked above); out_len > 0 (checked above). let o = unsafe { slice::from_raw_parts_mut(out, out_len) }; let params = soliton::primitives::argon2::Argon2Params { m_cost, t_cost, p_cost, }; match soliton::primitives::argon2::argon2id(pw, s, params, o) { Ok(()) => 0, Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // KEX: session establishment // ═══════════════════════════════════════════════════════════════════════ /// Sign a pre-key's public key with the identity key. /// /// `ik_sk` / `ik_sk_len`: identity secret key. /// `spk_pub` / `spk_pub_len`: pre-key public key (X-Wing, 1216 bytes). /// `sig_out`: receives the hybrid signature (caller frees). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_sign_prekey( ik_sk: *const u8, ik_sk_len: usize, spk_pub: *const u8, spk_pub_len: usize, sig_out: *mut SolitonBuf, ) -> i32 { if sig_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(sig_out as *mut u8, 0, std::mem::size_of::()) }; if ik_sk.is_null() || spk_pub.is_null() { return SolitonError::NullPointer as i32; } check_len!(ik_sk_len, soliton::constants::LO_SECRET_KEY_SIZE); check_len!(spk_pub_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); let sk_bytes = unsafe { slice::from_raw_parts(ik_sk, ik_sk_len) }; let pk_bytes = unsafe { slice::from_raw_parts(spk_pub, spk_pub_len) }; let secret_key = try_capi!(IdentitySecretKey::from_bytes(sk_bytes.to_vec())); let spk = try_capi!(xwing::PublicKey::from_bytes(pk_bytes.to_vec())); match soliton::kex::sign_prekey(&secret_key, &spk) { Ok(sig) => { unsafe { *sig_out = SolitonBuf::from_vec(sig.as_bytes().to_vec()) }; 0 } Err(e) => error_to_code(e), } } /// Result of session initiation, returned as a flat C struct. /// /// Contains both the encoded session init blob (`session_init_encoded`) and /// the individual fields. Callers should send the individual fields over the /// wire so that Bob can pass them to `soliton_kex_receive`. /// /// **Key usage order (critical):** /// 1. Pass `initial_chain_key` to `soliton_ratchet_encrypt_first` to /// encrypt the first application message. Do NOT pass it as a raw key /// to `soliton_aead_encrypt`. /// 2. Pass `root_key` AND the `ratchet_init_key_out` from /// `soliton_ratchet_encrypt_first` (as `chain_key`) to /// `soliton_ratchet_init_alice`. Do NOT pass `initial_chain_key` /// as `chain_key` — that is the pre-encrypt value. /// /// Using the wrong key at either step produces no immediate error — AEAD /// encryption succeeds with any 32-byte key. The mismatch only surfaces /// when the receiver fails to decrypt. /// /// Inline key fields (`root_key`, `initial_chain_key`, `sender_ik_fingerprint`, /// `recipient_ik_fingerprint`) are zeroized by `soliton_kex_initiated_session_free`. /// Do NOT use `memset`/`free` directly — call the free function for safe cleanup. /// /// # Struct Copy Warning /// /// C struct assignment (`SolitonInitiatedSession copy = *out;`) or `memcpy` /// creates a bitwise copy containing `root_key` and `initial_chain_key`. /// `soliton_kex_initiated_session_free` only zeroizes the original — the copy /// retains secret keys indefinitely. Avoid copying this struct; pass by /// pointer and free as soon as keys have been extracted. #[repr(C)] /// # GC Safety /// /// `root_key` and `initial_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. To mitigate: /// - Pin the struct in unmanaged memory (C# `Marshal.AllocHGlobal`, /// Go `C.malloc`, Python `ctypes.create_string_buffer`) /// - Call `soliton_kex_initiated_session_free` as soon as the keys have been /// passed to `soliton_ratchet_init_alice` — minimize the pinned lifetime /// - Do NOT copy this struct to managed heap objects pub struct SolitonInitiatedSession { /// Encoded session init message (caller frees). pub session_init_encoded: SolitonBuf, /// Root key (32 bytes, inline). pub root_key: [u8; 32], /// Initial chain key (32 bytes, inline). pub initial_chain_key: [u8; 32], /// Alice's ephemeral X-Wing public key (caller frees). pub ek_pk: SolitonBuf, /// Alice's ephemeral X-Wing secret key. SECURITY: free via /// `soliton_kex_initiated_session_free` only — do NOT call /// `soliton_buf_free` on this field directly. pub ek_sk: SolitonBuf, /// SHA3-256(Alice.IK_pub) — 32 bytes, inline. pub sender_ik_fingerprint: [u8; 32], /// SHA3-256(Bob.IK_pub) — 32 bytes, inline. Pass to `soliton_kex_receive` /// as `recipient_ik_fingerprint` when transmitting individual fields over the wire. pub recipient_ik_fingerprint: [u8; 32], /// X-Wing ciphertext encapsulated to Bob's IK (caller frees). pub ct_ik: SolitonBuf, /// X-Wing ciphertext encapsulated to Bob's SPK (caller frees). pub ct_spk: SolitonBuf, /// Bob's signed pre-key ID. pub spk_id: u32, /// X-Wing ciphertext encapsulated to Bob's OPK (caller frees; ptr=null if absent). pub ct_opk: SolitonBuf, /// Bob's one-time pre-key ID (0 if OPK absent). pub opk_id: u32, /// Whether an OPK was used (0 = no, 1 = yes). /// /// `#[repr(C)]` inserts 3 bytes of padding between this `u8` and the next /// pointer-aligned `SolitonBuf` on 64-bit targets (`has_opk` at offset 236, /// next 8-aligned boundary at 240). `soliton_kex_initiate` zero-initializes /// the output struct via `write_bytes`, then uses field-by-field assignment /// to preserve zeroed padding bytes. pub has_opk: u8, /// Alice's hybrid signature over the encoded SessionInit (caller frees via soliton_kex_initiated_session_free). pub sender_sig: SolitonBuf, } /// Initiate a session (Alice's side, §5.4). /// /// `alice_ik_pk` / `alice_ik_pk_len`: Alice's identity public key. /// `alice_ik_sk` / `alice_ik_sk_len`: Alice's identity secret key (used to sign the SessionInit). /// `bob_ik_pk` / `bob_ik_pk_len`: Bob's identity public key (from bundle). /// `bob_spk_pub` / `bob_spk_pub_len`: Bob's signed pre-key public key. /// `bob_spk_id`: Bob's signed pre-key ID. /// `bob_spk_sig` / `bob_spk_sig_len`: Hybrid signature over Bob's SPK. /// `bob_opk_pub` / `bob_opk_pub_len`: Bob's one-time pre-key (null if absent). /// `bob_opk_id`: Bob's one-time pre-key ID (unused when opk is null; value is discarded). /// `crypto_version`: null-terminated crypto version string. /// `out`: receives the initiated session result, including `sender_sig` (Alice's /// hybrid signature over the encoded SessionInit). /// /// # Security /// /// `out.ek_sk` contains the ephemeral secret key. A second heap copy exists /// briefly alongside the original inside the `InitiatedSession` struct. /// Call `soliton_kex_initiated_session_free` as soon as the session result /// is no longer needed to minimize the lifetime of both copies. /// Do NOT use manual `free()` calls — the free function zeroizes secret key /// buffers before releasing memory. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): a key or signature buffer has the wrong /// size. Required sizes: `alice_ik_pk_len` = `SOLITON_PUBLIC_KEY_SIZE` (3200), /// `alice_ik_sk_len` = `SOLITON_SECRET_KEY_SIZE` (2496), /// `bob_ik_pk_len` = `SOLITON_PUBLIC_KEY_SIZE` (3200), /// `bob_spk_pub_len` = `SOLITON_XWING_PK_SIZE` (1216), /// `bob_spk_sig_len` = `SOLITON_HYBRID_SIG_SIZE` (3373), /// `bob_opk_pub_len` (if non-null) = `SOLITON_XWING_PK_SIZE` (1216). /// - `SOLITON_ERR_INVALID_DATA` (-17): all-zero key. /// - `SOLITON_ERR_BUNDLE` (-5): IK mismatch, SPK signature verification failed, /// or crypto version mismatch (collapsed to avoid oracle). /// - `SOLITON_ERR_CRYPTO_VERSION` (-16): unrecognized `crypto_version` in /// the session init wire format (decode path, not bundle verification). /// /// Returns 0 on success, negative on error. On error, `out` is zeroed; /// it is safe (but unnecessary) to call `soliton_kex_initiated_session_free` on it. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_initiate( alice_ik_pk: *const u8, alice_ik_pk_len: usize, alice_ik_sk: *const u8, alice_ik_sk_len: usize, bob_ik_pk: *const u8, bob_ik_pk_len: usize, bob_spk_pub: *const u8, bob_spk_pub_len: usize, bob_spk_id: u32, bob_spk_sig: *const u8, bob_spk_sig_len: usize, bob_opk_pub: *const u8, bob_opk_pub_len: usize, bob_opk_id: u32, crypto_version: *const c_char, out: *mut SolitonInitiatedSession, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( out as *mut u8, 0, std::mem::size_of::(), ) }; if alice_ik_pk.is_null() || alice_ik_sk.is_null() || bob_ik_pk.is_null() || bob_spk_pub.is_null() || bob_spk_sig.is_null() || crypto_version.is_null() { return SolitonError::NullPointer as i32; } // Exact-size guards: validate before .to_vec() to prevent allocation // amplification from oversized caller-provided lengths. if alice_ik_pk_len != soliton::constants::LO_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if alice_ik_sk_len != soliton::constants::LO_SECRET_KEY_SIZE { return SolitonError::InvalidLength as i32; } if bob_ik_pk_len != soliton::constants::LO_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if bob_spk_pub_len != soliton::constants::XWING_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if bob_spk_sig_len != soliton::constants::HYBRID_SIGNATURE_SIZE { return SolitonError::InvalidLength as i32; } let alice_pk_bytes = unsafe { slice::from_raw_parts(alice_ik_pk, alice_ik_pk_len) }; let alice_sk_bytes = unsafe { slice::from_raw_parts(alice_ik_sk, alice_ik_sk_len) }; let bob_pk_bytes = unsafe { slice::from_raw_parts(bob_ik_pk, bob_ik_pk_len) }; let spk_bytes = unsafe { slice::from_raw_parts(bob_spk_pub, bob_spk_pub_len) }; let sig_bytes = unsafe { slice::from_raw_parts(bob_spk_sig, bob_spk_sig_len) }; let cv = match unsafe { cstr_to_str(crypto_version) } { Ok(s) => s, Err(code) => return code, }; let alice_pk = try_capi!(IdentityPublicKey::from_bytes(alice_pk_bytes.to_vec())); let alice_sk = try_capi!(IdentitySecretKey::from_bytes(alice_sk_bytes.to_vec())); let bob_pk = try_capi!(IdentityPublicKey::from_bytes(bob_pk_bytes.to_vec())); let spk_sig = try_capi!(HybridSignature::from_bytes(sig_bytes.to_vec())); // Co-presence guard: OPK is optional, but null+nonzero or non-null+zero // are invalid — they indicate a caller logic bug. if bob_opk_pub.is_null() && bob_opk_pub_len != 0 { return SolitonError::NullPointer as i32; } if !bob_opk_pub.is_null() && bob_opk_pub_len != soliton::constants::XWING_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } let opk = if bob_opk_pub.is_null() { None } else { let opk_bytes = unsafe { slice::from_raw_parts(bob_opk_pub, bob_opk_pub_len) }; match xwing::PublicKey::from_bytes(opk_bytes.to_vec()) { Ok(pk) => Some(pk), Err(e) => return error_to_code(e), } }; let spk = try_capi!(xwing::PublicKey::from_bytes(spk_bytes.to_vec())); let opk_id = opk.as_ref().map(|_| bob_opk_id); let bundle = soliton::kex::PreKeyBundle { ik_pub: bob_pk.clone(), crypto_version: cv.to_string(), spk_pub: spk, spk_id: bob_spk_id, spk_sig, opk_pub: opk, opk_id, }; // Verify bundle before initiation — enforced by VerifiedBundle type. let verified = try_capi!(soliton::kex::verify_bundle(bundle, &bob_pk)); match soliton::kex::initiate_session(&alice_pk, &alice_sk, &verified) { Ok(mut session) => { // On error from either initiate_session or encode_session_init, // `session` drops here; ZeroizeOnDrop on InitiatedSession ensures // all key material (root_key, chain_key, ek_sk) is zeroized. // The pre-zeroed *out struct remains in a safe/freeable state. // // Extract all immutable data from session_init BEFORE calling // take_ methods (which require &mut self). let si_encoded = try_capi!(soliton::kex::encode_session_init(&session.session_init)); let (ct_opk_buf, opk_id_val, has_opk_val) = match (&session.session_init.ct_opk, session.session_init.opk_id) { (Some(ct), Some(id)) => (SolitonBuf::from_vec(ct.as_bytes().to_vec()), id, 1u8), _ => (SolitonBuf::empty(), 0u32, 0u8), }; let sender_ik_fp = session.session_init.sender_ik_fingerprint; let recipient_ik_fp = session.session_init.recipient_ik_fingerprint; let ct_ik_buf = SolitonBuf::from_vec(session.session_init.ct_ik.as_bytes().to_vec()); let ct_spk_buf = SolitonBuf::from_vec(session.session_init.ct_spk.as_bytes().to_vec()); let spk_id_val = session.session_init.spk_id; let ek_pk_buf = SolitonBuf::from_vec(session.ek_pk.as_bytes().to_vec()); let ek_sk_buf = SolitonBuf::from_vec(session.ek_sk().as_bytes().to_vec()); let sender_sig_buf = SolitonBuf::from_vec(session.sender_sig.as_bytes().to_vec()); // take_ methods extract secret key material and replace internal // copies with zeros, enforcing single-use at the type level. let rk = session.take_root_key(); let ck = session.take_initial_chain_key(); // Multiple SolitonBuf::from_vec allocations above. If any panicked // before the *out write completes, already-allocated buffers would // leak. Under the `panic = "abort"` profile the process terminates, // so no leak occurs. // Field-by-field assignment preserves the zero-initialized padding // bytes from the `write_bytes` call at function entry. Full struct // assignment (`*out = SolitonInitiatedSession { ... }`) would copy // from a stack temporary whose padding bytes are indeterminate, // potentially leaking stack residue across the FFI boundary. unsafe { (*out).session_init_encoded = SolitonBuf::from_vec(si_encoded); (*out).root_key = *rk; (*out).initial_chain_key = *ck; (*out).ek_pk = ek_pk_buf; (*out).ek_sk = ek_sk_buf; (*out).sender_ik_fingerprint = sender_ik_fp; (*out).recipient_ik_fingerprint = recipient_ik_fp; (*out).ct_ik = ct_ik_buf; (*out).ct_spk = ct_spk_buf; (*out).spk_id = spk_id_val; (*out).ct_opk = ct_opk_buf; (*out).opk_id = opk_id_val; (*out).has_opk = has_opk_val; (*out).sender_sig = sender_sig_buf; } 0 } Err(e) => error_to_code(e), } } /// Free an initiated session result. /// /// Zeroizes inline key material (root_key, initial_chain_key, /// sender_ik_fingerprint, recipient_ik_fingerprint) and frees library-allocated /// buffers (session_init_encoded, ek_pk, ek_sk, ct_ik, ct_spk, ct_opk, sender_sig). #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_initiated_session_free(session: *mut SolitonInitiatedSession) { if !session.is_null() { unsafe { let s = &mut *session; // Inline [u8; 32] fields are not covered by ZeroizeOnDrop (the // parent struct is #[repr(C)], not a Zeroize-derived type) — they // must be explicitly zeroized before freeing the heap-allocated bufs. s.root_key.zeroize(); s.initial_chain_key.zeroize(); s.sender_ik_fingerprint.zeroize(); s.recipient_ik_fingerprint.zeroize(); soliton_buf_free(&mut s.session_init_encoded); soliton_buf_free(&mut s.ek_pk); soliton_buf_free(&mut s.ek_sk); soliton_buf_free(&mut s.ct_ik); soliton_buf_free(&mut s.ct_spk); soliton_buf_free(&mut s.ct_opk); soliton_buf_free(&mut s.sender_sig); s.has_opk = 0; // spk_id and opk_id are public protocol identifiers — not secret; // intentionally not zeroed. } } } /// Wire fields of a received session init message (Bob's input, §5.5). /// /// All fields are transmitted by Alice and received by Bob over the network. /// Populate this struct from the deserialized message before calling /// [`soliton_kex_receive`]. The struct holds raw pointers into caller-owned /// buffers and neither allocates nor frees memory. /// /// Field names correspond to [`SolitonInitiatedSession`] fields (excluding /// Alice-only fields such as `root_key`, `ek_sk`, and `session_init_encoded`). /// /// `sender_ik_fingerprint` and `recipient_ik_fingerprint` are fixed-size /// (32 bytes each); the `_len` fields must be exactly 32. #[repr(C)] pub struct SolitonSessionInitWire { /// Alice's hybrid signature over the encoded SessionInit (caller-owned). pub sender_sig: *const u8, pub sender_sig_len: usize, /// SHA3-256(Alice.IK_pub) — must point to exactly 32 bytes. pub sender_ik_fingerprint: *const u8, pub sender_ik_fingerprint_len: usize, /// SHA3-256(Bob.IK_pub) — must point to exactly 32 bytes. pub recipient_ik_fingerprint: *const u8, pub recipient_ik_fingerprint_len: usize, /// Alice's ephemeral X-Wing public key (caller-owned). pub sender_ek: *const u8, pub sender_ek_len: usize, /// X-Wing ciphertext encapsulated to Bob's IK (caller-owned). pub ct_ik: *const u8, pub ct_ik_len: usize, /// X-Wing ciphertext encapsulated to Bob's SPK (caller-owned). pub ct_spk: *const u8, pub ct_spk_len: usize, /// Bob's signed pre-key ID. Must match the key in `SolitonSessionDecapKeys`. pub spk_id: u32, /// X-Wing ciphertext encapsulated to Bob's OPK (null if no OPK was used). pub ct_opk: *const u8, pub ct_opk_len: usize, /// Bob's one-time pre-key ID (must be 0 when `ct_opk` is null; non-zero returns `SOLITON_ERR_INVALID_DATA`). pub opk_id: u32, /// Null-terminated crypto version string (e.g. `"lo-crypto-v1"`). pub crypto_version: *const c_char, } /// Bob's pre-key secret keys needed to decapsulate a received session init (§5.5). /// /// Bob fetches these from his key store using `spk_id` and `opk_id` from /// [`SolitonSessionInitWire`]. The struct holds raw pointers into caller-owned /// buffers and neither allocates nor frees memory. /// /// If `ct_opk` in the wire struct is null, `opk_sk` must also be null. /// Providing `opk_sk` when `ct_opk` is null is rejected as `InvalidData`. #[repr(C)] pub struct SolitonSessionDecapKeys { /// Bob's signed pre-key secret key (X-Wing, 2432 bytes, caller-owned). pub spk_sk: *const u8, pub spk_sk_len: usize, /// Bob's one-time pre-key secret key (null if OPK was not used). pub opk_sk: *const u8, pub opk_sk_len: usize, } /// Result of successful session receipt (Bob's side, §5.5). /// /// Symmetric with [`SolitonInitiatedSession`] on Alice's side. /// /// # GC Safety /// /// `root_key` and `chain_key` are inline secret material. See /// [`SolitonInitiatedSession`] for GC-safety guidance — the same /// pinning and lifetime-minimization advice applies. /// /// **Key usage order (critical):** /// 1. Pass `chain_key` to `soliton_ratchet_decrypt_first` to decrypt Alice's /// first message. Do NOT use it as a raw AEAD key. /// 2. Pass `root_key` AND the `ratchet_init_key_out` from /// `soliton_ratchet_decrypt_first` (as `chain_key`) to /// `soliton_ratchet_init_bob`. Do NOT pass `chain_key` from this struct /// as `chain_key` — that is the pre-decrypt value. /// /// Using the wrong key at either step produces no immediate error. The mismatch /// only surfaces when decryption fails. /// /// `root_key` and `chain_key` are zeroized by /// `soliton_kex_received_session_free`. `peer_ek` is library-allocated and /// freed by the same function. Do NOT call `soliton_buf_free` on `peer_ek` /// directly; use `soliton_kex_received_session_free` for safe cleanup. #[repr(C)] pub struct SolitonReceivedSession { /// Root key (32 bytes, inline). Zeroized by `soliton_kex_received_session_free`. pub root_key: [u8; 32], /// Initial chain key (32 bytes, inline). Zeroized by `soliton_kex_received_session_free`. pub chain_key: [u8; 32], /// Alice's ephemeral X-Wing public key (library-allocated; freed by /// `soliton_kex_received_session_free`). pub peer_ek: SolitonBuf, } /// Free a received session result, zeroizing inline key material. /// /// Zeroizes `root_key` and `chain_key` and frees `peer_ek`. /// Safe to call on a null pointer (no-op). #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_received_session_free(session: *mut SolitonReceivedSession) { if !session.is_null() { unsafe { let s = &mut *session; s.root_key.zeroize(); s.chain_key.zeroize(); soliton_buf_free(&mut s.peer_ek); } } } /// Receive a session init (Bob's side, §5.5). /// /// `bob_ik_pk` / `bob_ik_pk_len`: Bob's identity public key. /// `bob_ik_sk` / `bob_ik_sk_len`: Bob's identity secret key. /// `alice_ik_pk` / `alice_ik_pk_len`: Alice's identity public key (from the trust store). /// `wire`: wire fields of the received SessionInit. Must be non-null; all pointer /// fields except `ct_opk` / `opk_sk` must be non-null. /// `decap_keys`: Bob's pre-key secret keys, fetched from the key store using /// `wire->spk_id` and `wire->opk_id`. Must be non-null; `spk_sk` must be non-null. /// `out`: receives the derived session keys on success; zeroed on error. Must be non-null. /// /// # Security /// /// `out->root_key` and `out->chain_key` contain sensitive key material. Zeroize /// them (via `soliton_kex_received_session_free` or `explicit_bzero`) immediately /// after passing them to `soliton_ratchet_decrypt_first` and `soliton_ratchet_init_bob` /// respectively. Do not store or copy them beyond those calls. /// /// # Caller Obligations — 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 for a given ID, the KEX /// will silently produce incorrect shared secrets and the first ratchet message /// will fail AEAD authentication. The caller must also handle unknown or expired /// key IDs (e.g., return an application-level error) before invoking this function. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any required pointer argument is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): a key, ciphertext, or signature buffer /// has the wrong size. /// - `SOLITON_ERR_INVALID_DATA` (-17): self-pair (Alice IK == Bob IK), all-zero /// key, sender signature verification failed, co-presence violation /// (`ct_opk`/`opk_sk` must both be present or both absent), or crypto version /// mismatch. /// - `SOLITON_ERR_DECAPSULATION` (-2): X-Wing decapsulation failed for IK, SPK, /// or OPK ciphertext. /// - `SOLITON_ERR_CRYPTO_VERSION` (-16): unrecognized `crypto_version`. /// /// Returns 0 on success, negative on error. On error `out` is zeroed; it is /// safe (but unnecessary) to call `soliton_kex_received_session_free` on it. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_receive( bob_ik_pk: *const u8, bob_ik_pk_len: usize, bob_ik_sk: *const u8, bob_ik_sk_len: usize, alice_ik_pk: *const u8, alice_ik_pk_len: usize, wire: *const SolitonSessionInitWire, decap_keys: *const SolitonSessionDecapKeys, out: *mut SolitonReceivedSession, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( out as *mut u8, 0, std::mem::size_of::(), ); } if bob_ik_pk.is_null() || bob_ik_sk.is_null() || alice_ik_pk.is_null() || wire.is_null() || decap_keys.is_null() { return SolitonError::NullPointer as i32; } // SAFETY: wire and decap_keys are non-null (checked above); callers are // responsible for providing valid, fully-populated structs. let w = unsafe { &*wire }; let dk = unsafe { &*decap_keys }; // Null-check mandatory wire pointer fields. if w.sender_sig.is_null() || w.sender_ik_fingerprint.is_null() || w.recipient_ik_fingerprint.is_null() || w.sender_ek.is_null() || w.ct_ik.is_null() || w.ct_spk.is_null() || w.crypto_version.is_null() { return SolitonError::NullPointer as i32; } if dk.spk_sk.is_null() { return SolitonError::NullPointer as i32; } // Exact-size guards: validate before .to_vec() to prevent allocation // amplification from oversized caller-provided lengths. if bob_ik_pk_len != soliton::constants::LO_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if bob_ik_sk_len != soliton::constants::LO_SECRET_KEY_SIZE { return SolitonError::InvalidLength as i32; } if alice_ik_pk_len != soliton::constants::LO_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if w.sender_sig_len != soliton::constants::HYBRID_SIGNATURE_SIZE { return SolitonError::InvalidLength as i32; } let bob_pk = try_capi!(IdentityPublicKey::from_bytes( unsafe { slice::from_raw_parts(bob_ik_pk, bob_ik_pk_len) }.to_vec() )); let bob_sk = try_capi!(IdentitySecretKey::from_bytes( unsafe { slice::from_raw_parts(bob_ik_sk, bob_ik_sk_len) }.to_vec() )); let alice_pk = try_capi!(IdentityPublicKey::from_bytes( unsafe { slice::from_raw_parts(alice_ik_pk, alice_ik_pk_len) }.to_vec() )); let sender_sig_val = try_capi!(HybridSignature::from_bytes( unsafe { slice::from_raw_parts(w.sender_sig, w.sender_sig_len) }.to_vec() )); let cv = match unsafe { cstr_to_str(w.crypto_version) } { Ok(s) => s, Err(code) => return code, }; if w.sender_ek_len != soliton::constants::XWING_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } if w.ct_ik_len != soliton::constants::XWING_CIPHERTEXT_SIZE { return SolitonError::InvalidLength as i32; } if w.ct_spk_len != soliton::constants::XWING_CIPHERTEXT_SIZE { return SolitonError::InvalidLength as i32; } if dk.spk_sk_len != soliton::constants::XWING_SECRET_KEY_SIZE { return SolitonError::InvalidLength as i32; } let sender_ek_pk = try_capi!(xwing::PublicKey::from_bytes( unsafe { slice::from_raw_parts(w.sender_ek, w.sender_ek_len) }.to_vec() )); let ct_ik_val = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(w.ct_ik, w.ct_ik_len) }.to_vec() )); let ct_spk_val = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(w.ct_spk, w.ct_spk_len) }.to_vec() )); let spk_sk_val = try_capi!(xwing::SecretKey::from_bytes( unsafe { slice::from_raw_parts(dk.spk_sk, dk.spk_sk_len) }.to_vec() )); // Co-presence guard: ct_opk is optional, but null+nonzero or non-null+zero // are invalid — they indicate a caller logic bug. if w.ct_opk.is_null() && w.ct_opk_len != 0 { return SolitonError::NullPointer as i32; } if !w.ct_opk.is_null() && w.ct_opk_len != soliton::constants::XWING_CIPHERTEXT_SIZE { return SolitonError::InvalidLength as i32; } // Reject non-null opk_sk when ct_opk is absent — the OPK secret key would // be silently ignored (since we pass None to the core when ct_opk is absent), // masking a caller logic bug. Not an oracle: both "has OPK" and "no OPK" // paths return InvalidData when ct_opk is absent (see §5.5 Step 2 note). if !dk.opk_sk.is_null() && w.ct_opk.is_null() { return SolitonError::InvalidData as i32; } // Co-presence: null opk_sk with non-zero length is a caller logic bug. if dk.opk_sk.is_null() && dk.opk_sk_len != 0 { return SolitonError::NullPointer as i32; } let (ct_opk_opt, opk_id_opt, opk_sk_opt) = if w.ct_opk.is_null() { // Reject non-zero opk_id when ct_opk is absent — a caller logic bug. if w.opk_id != 0 { return SolitonError::InvalidData as i32; } (None, None, None) } else { let ct = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(w.ct_opk, w.ct_opk_len) }.to_vec() )); // opk_sk is optional here — if the caller passes null (e.g., OPK was // already consumed/deleted), pass None to the core and let it reject // post-signature (§5.5 Step 4). Returning NullPointer here would create // an OPK-presence oracle: an attacker could distinguish "OPK present, // no opk_sk" from "OPK absent" before signature verification. let sk = if dk.opk_sk.is_null() { None } else if dk.opk_sk_len != soliton::constants::XWING_SECRET_KEY_SIZE { return SolitonError::InvalidLength as i32; } else { Some(try_capi!(xwing::SecretKey::from_bytes( unsafe { slice::from_raw_parts(dk.opk_sk, dk.opk_sk_len) }.to_vec() ))) }; (Some(ct), Some(w.opk_id), sk) }; // Build the SessionInit using wire fingerprints (not recomputed from keys). // The core library verifies sender_ik_fingerprint matches SHA3-256(alice_ik_pk) and // recipient_ik_fingerprint matches SHA3-256(bob_ik_pk) before signature verification. check_len!(w.sender_ik_fingerprint_len, 32); check_len!(w.recipient_ik_fingerprint_len, 32); // SAFETY: both fingerprint pointers are non-null (checked above) and validated to be 32 bytes. let sender_fp = unsafe { *(w.sender_ik_fingerprint as *const [u8; 32]) }; let recipient_fp = unsafe { *(w.recipient_ik_fingerprint as *const [u8; 32]) }; let si = soliton::kex::SessionInit { crypto_version: cv.to_string(), sender_ik_fingerprint: sender_fp, recipient_ik_fingerprint: recipient_fp, sender_ek: sender_ek_pk, ct_ik: ct_ik_val, ct_spk: ct_spk_val, spk_id: w.spk_id, ct_opk: ct_opk_opt, opk_id: opk_id_opt, }; match soliton::kex::receive_session( &bob_pk, &bob_sk, &alice_pk, &si, &sender_sig_val, &spk_sk_val, opk_sk_opt.as_ref(), ) { Ok(mut session) => { // take_ methods extract secret key material and replace internal // copies with zeros, enforcing single-use at the type level. let rk = session.take_root_key(); let ck = session.take_initial_chain_key(); unsafe { let o = &mut *out; std::ptr::copy_nonoverlapping(rk.as_ptr(), o.root_key.as_mut_ptr(), 32); std::ptr::copy_nonoverlapping(ck.as_ptr(), o.chain_key.as_mut_ptr(), 32); o.peer_ek = SolitonBuf::from_vec(session.peer_ek.as_bytes().to_vec()); } 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // Verification phrase // ═══════════════════════════════════════════════════════════════════════ /// Generate a verification phrase from two identity public keys. /// /// `pk_a` / `pk_a_len`: first identity public key. /// `pk_b` / `pk_b_len`: second identity public key. /// `phrase_out`: receives the phrase as a null-terminated string (caller frees). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_verification_phrase( pk_a: *const u8, pk_a_len: usize, pk_b: *const u8, pk_b_len: usize, phrase_out: *mut SolitonBuf, ) -> i32 { if phrase_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(phrase_out as *mut u8, 0, std::mem::size_of::()) }; if pk_a.is_null() || pk_b.is_null() { return SolitonError::NullPointer as i32; } // Exact-size guard: verification phrases require exactly LO_PUBLIC_KEY_SIZE // per key — partial keys would produce incorrect fingerprints. Unlike other // functions that accept variable-length inputs, this rejects early to give // callers a clear error before the core lib's own length check. if pk_a_len != soliton::constants::LO_PUBLIC_KEY_SIZE || pk_b_len != soliton::constants::LO_PUBLIC_KEY_SIZE { return SolitonError::InvalidLength as i32; } let a = unsafe { slice::from_raw_parts(pk_a, pk_a_len) }; let b = unsafe { slice::from_raw_parts(pk_b, pk_b_len) }; let phrase = try_capi!(soliton::verification::verification_phrase(a, b)); let mut bytes = phrase.into_bytes(); // C callers expect null-terminated strings. bytes.push(0); unsafe { *phrase_out = SolitonBuf::from_vec(bytes) }; 0 } // ═══════════════════════════════════════════════════════════════════════ // Ratchet state serialization // ═══════════════════════════════════════════════════════════════════════ /// Serialize a ratchet state to bytes, consuming the ratchet. /// /// `ratchet`: pointer to the opaque ratchet pointer. On success, `*ratchet` is /// set to null — the ratchet is consumed and must not be used or freed /// after this call. To continue using the ratchet, deserialize the /// returned bytes with `soliton_ratchet_from_bytes`. /// `data_out`: receives the serialized state (caller frees with `soliton_buf_free`). /// `epoch_out`: if non-null, receives the monotonic epoch embedded in the /// serialized blob. The caller should persist this value and pass it as /// `min_epoch` to [`soliton_ratchet_from_bytes_with_min_epoch`] on the next /// load to enforce anti-rollback protection. If null, the epoch is not /// returned (backward-compatible). /// /// # Ownership /// /// This function consumes the ratchet to prevent session forking: if the caller /// could serialize and keep using the original, a later restore would produce a /// duplicate ratchet with the same chain keys and counters — catastrophic /// AEAD nonce reuse. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): `ratchet`, `*ratchet`, or `data_out` is null. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): ratchet handle is in use by another call. /// - `SOLITON_ERR_CHAIN_EXHAUSTED` (-15): a counter is at `u32::MAX`, preventing /// safe epoch increment during serialization. On this error, `*ratchet` is NOT /// consumed — the handle remains valid and the session is still usable. The /// caller should send/receive more messages to advance the ratchet state, then /// retry serialization. /// /// # Security /// The returned buffer contains ALL ratchet secret key material (root key, /// chain keys, ratchet secret key). MUST be freed with `soliton_buf_free()`. /// Calling `free()` directly on `ptr` skips zeroization and leaves ephemeral /// key material in freed heap memory — this is a security defect, not merely /// an API violation. Do not copy into unmanaged memory. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_to_bytes( ratchet: *mut *mut SolitonRatchet, data_out: *mut SolitonBuf, epoch_out: *mut u64, ) -> i32 { if data_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(data_out as *mut u8, 0, std::mem::size_of::()) }; // Zero epoch_out early so error paths never leave stale values. if !epoch_out.is_null() { unsafe { *epoch_out = 0 }; } if ratchet.is_null() { return SolitonError::NullPointer as i32; } let inner = unsafe { *ratchet }; if inner.is_null() { return SolitonError::NullPointer as i32; } if unsafe { &*inner }.magic != MAGIC_RATCHET { return SolitonError::InvalidData as i32; } // Manual CAS (not RAII) — the handle is consumed on success, so the // AtomicBool will be freed. On the error path (ChainExhausted), the // lock is explicitly released (line below). The CAS prevents consuming // a handle that another thread is actively using. if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } // Pre-check: verify serialization can succeed before consuming the handle. // to_bytes() returns ChainExhausted if any counter is at u32::MAX. Without // this check, the Box::from_raw below would take ownership, and a failure // in to_bytes() would drop (zeroize) the ratchet — irrecoverable session loss. // No TOCTOU race: the CAS lock above prevents concurrent mutation between // can_serialize() and to_bytes(). if !unsafe { &*inner }.inner.can_serialize() { unsafe { &*inner }.in_use.store(false, Ordering::Release); return SolitonError::ChainExhausted as i32; } // Take ownership of the ratchet. The Box drop zeroizes the state after // to_bytes extracts the serialized form. // SAFETY: inner was created by Box::into_raw in init_alice/init_bob/from_bytes. let owned = unsafe { Box::from_raw(inner) }; // Null the caller's pointer immediately — the ratchet is consumed regardless // of whether serialization succeeds. unsafe { *ratchet = std::ptr::null_mut() }; // Defense-in-depth: can_serialize() pre-check above makes to_bytes() // infallible today (ChainExhausted is the only error). The try_capi! is // retained so that if a future refactor adds a new error variant to // to_bytes(), the error propagates cleanly rather than panicking. let (mut bytes, epoch) = try_capi!(owned.inner.to_bytes()); // Return the epoch embedded in the blob so the caller can persist it // for anti-rollback enforcement via from_bytes_with_min_epoch. if !epoch_out.is_null() { unsafe { *epoch_out = epoch }; } // Move the Vec out of Zeroizing — the wrapper drops and zeroizes the // now-empty Vec. Ownership transfers to C (freed via soliton_buf_free). let raw: Vec = std::mem::take(&mut *bytes); unsafe { *data_out = SolitonBuf::from_vec(raw) }; 0 } /// Deserialize a ratchet state from bytes. /// /// `data` / `data_len`: serialized ratchet state (produced by `soliton_ratchet_to_bytes`; /// maximum 1 MiB / 1048576 bytes). /// `out`: receives an opaque `SolitonRatchet*`. On error, `*out` is set to null. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): `data` or `out` is null. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `data_len` is 0 or exceeds 1 MiB. /// - `SOLITON_ERR_INVALID_DATA` (-17): blob is structurally invalid (bad magic, /// truncated, corrupted fields). Also returned for anti-rollback rejection /// when using `soliton_ratchet_from_bytes_with_min_epoch` — callers cannot /// distinguish corruption from rollback programmatically (by design: an /// attacker replaying an old blob should not learn which check failed). /// - `SOLITON_ERR_VERSION` (-10): blob version byte is unrecognized. /// /// # Security /// /// On success the caller owns the returned `SolitonRatchet*` and MUST free it /// with `soliton_ratchet_free`. The ratchet holds all session key material /// (root key, chain keys, ratchet secret key); `soliton_ratchet_free` /// zeroizes this material before releasing memory. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_from_bytes( data: *const u8, data_len: usize, out: *mut *mut SolitonRatchet, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if data.is_null() { return SolitonError::NullPointer as i32; } if data_len == 0 { return SolitonError::InvalidLength as i32; } // Upper bound: a serialized ratchet state is bounded (~100 KiB with // maximum skip cache). Reject wildly oversized lengths to prevent // allocation amplification from untrusted caller-provided values. if data_len > 1_048_576 { return SolitonError::InvalidLength as i32; } let bytes = unsafe { slice::from_raw_parts(data, data_len) }; // min_epoch = 0 disables anti-rollback — equivalent to the deprecated from_bytes. // Callers who need rollback protection should use soliton_ratchet_from_bytes_with_min_epoch. match soliton::ratchet::RatchetState::from_bytes_with_min_epoch(bytes, 0) { Ok(state) => { unsafe { *out = Box::into_raw(Box::new(SolitonRatchet { magic: MAGIC_RATCHET, inner: state, in_use: AtomicBool::new(false), })) }; 0 } Err(e) => error_to_code(e), } } /// Read the anti-rollback epoch from a ratchet state. /// /// `ratchet`: opaque ratchet state. Must be non-null. /// `epoch_out`: receives the epoch value. Must be non-null. /// /// The epoch is monotonically increasing per `to_bytes` call. The caller /// should persist this value and pass it as `min_epoch` to /// `soliton_ratchet_from_bytes_with_min_epoch` on the next load. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_epoch( ratchet: *const SolitonRatchet, epoch_out: *mut u64, ) -> i32 { if epoch_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *epoch_out = 0; } if ratchet.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*ratchet }, MAGIC_RATCHET); let r = unsafe { &*ratchet }; unsafe { *epoch_out = r.inner.epoch(); } 0 } /// Deserialize a ratchet state from bytes with anti-rollback epoch validation. /// /// Equivalent to `soliton_ratchet_from_bytes` but additionally rejects blobs /// whose epoch is ≤ `min_epoch`. This prevents storage-level replay attacks /// where an attacker substitutes an older encrypted ratchet blob. /// /// `data` / `data_len`: serialized ratchet state (maximum 1 MiB / 1048576 bytes). /// `min_epoch`: the epoch from the last successfully loaded state. /// `out`: receives an opaque `SolitonRatchet*`. On error, `*out` is set to null. /// /// # Anti-Rollback Protocol /// /// 1. Load: `soliton_ratchet_from_bytes_with_min_epoch(blob, last_epoch, &ratchet)` /// 2. On success, read the new epoch via `soliton_ratchet_epoch(ratchet)` and /// persist it as the new `last_epoch`. /// 3. On next load, pass the persisted epoch as `min_epoch`. /// /// # Possible Errors /// /// Same as `soliton_ratchet_from_bytes`, plus anti-rollback rejection /// (`SOLITON_ERR_INVALID_DATA` when `epoch ≤ min_epoch`). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_from_bytes_with_min_epoch( data: *const u8, data_len: usize, min_epoch: u64, out: *mut *mut SolitonRatchet, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if data.is_null() { return SolitonError::NullPointer as i32; } if data_len == 0 { return SolitonError::InvalidLength as i32; } if data_len > 1_048_576 { return SolitonError::InvalidLength as i32; } let bytes = unsafe { slice::from_raw_parts(data, data_len) }; match soliton::ratchet::RatchetState::from_bytes_with_min_epoch(bytes, min_epoch) { Ok(state) => { unsafe { *out = Box::into_raw(Box::new(SolitonRatchet { magic: MAGIC_RATCHET, inner: state, in_use: AtomicBool::new(false), })) }; 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // KEX: verify_bundle, build_first_message_aad // ═══════════════════════════════════════════════════════════════════════ /// Verify a pre-key bundle (§5.4 Step 1). /// /// Checks that: /// 1. `bundle_ik_pk` matches `known_ik_pk`. /// 2. The SPK signature `spk_sig` is valid over `spk_pub`. /// 3. The crypto version matches the library's supported version. /// /// `bundle_ik_pk` / `bundle_ik_pk_len`: identity public key from the bundle. /// `known_ik_pk` / `known_ik_pk_len`: the known/trusted identity public key for this peer. /// `spk_pub` / `spk_pub_len`: signed pre-key public key (X-Wing, 1216 bytes). /// `spk_sig` / `spk_sig_len`: hybrid signature over the SPK. /// `crypto_version`: null-terminated crypto version string. /// /// Returns 0 if valid, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_verify_bundle( bundle_ik_pk: *const u8, bundle_ik_pk_len: usize, known_ik_pk: *const u8, known_ik_pk_len: usize, spk_pub: *const u8, spk_pub_len: usize, spk_sig: *const u8, spk_sig_len: usize, crypto_version: *const c_char, ) -> i32 { if bundle_ik_pk.is_null() || known_ik_pk.is_null() || spk_pub.is_null() || spk_sig.is_null() || crypto_version.is_null() { return SolitonError::NullPointer as i32; } check_len!(bundle_ik_pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); check_len!(known_ik_pk_len, soliton::constants::LO_PUBLIC_KEY_SIZE); check_len!(spk_pub_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); check_len!(spk_sig_len, soliton::constants::HYBRID_SIGNATURE_SIZE); let bundle_pk = try_capi!(IdentityPublicKey::from_bytes( unsafe { slice::from_raw_parts(bundle_ik_pk, bundle_ik_pk_len) }.to_vec() )); let known_pk = try_capi!(IdentityPublicKey::from_bytes( unsafe { slice::from_raw_parts(known_ik_pk, known_ik_pk_len) }.to_vec() )); let sig = try_capi!(HybridSignature::from_bytes( unsafe { slice::from_raw_parts(spk_sig, spk_sig_len) }.to_vec() )); let cv = match unsafe { cstr_to_str(crypto_version) } { Ok(s) => s, Err(code) => return code, }; let spk_bytes = unsafe { slice::from_raw_parts(spk_pub, spk_pub_len) }; let spk = try_capi!(xwing::PublicKey::from_bytes(spk_bytes.to_vec())); // spk_id, opk_pub, and opk_id are unused by verify_bundle — it only // checks IK and SPK signature. Placeholder values are safe because // the VerifiedBundle type is consumed immediately and never stored. let bundle = soliton::kex::PreKeyBundle { ik_pub: bundle_pk, crypto_version: cv.to_string(), spk_pub: spk, spk_id: 0, spk_sig: sig, opk_pub: None, opk_id: None, }; match soliton::kex::verify_bundle(bundle, &known_pk) { Ok(_verified) => 0, Err(e) => error_to_code(e), } } /// Build AAD for the first message of a session (§7.3). /// /// This is needed by Bob when calling `soliton_ratchet_decrypt_first` — /// both sides must use identical AAD. /// /// `sender_fp`: sender's fingerprint (32 bytes). /// `recipient_fp`: recipient's fingerprint (32 bytes). /// `session_init_encoded` / `session_init_encoded_len`: encoded session init /// (as returned in `SolitonInitiatedSession::session_init_encoded`; /// maximum 8 KiB / 8192 bytes). /// `aad_out`: receives the AAD bytes (caller frees with `soliton_buf_free`). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_build_first_message_aad( sender_fp: *const u8, sender_fp_len: usize, recipient_fp: *const u8, recipient_fp_len: usize, session_init_encoded: *const u8, session_init_encoded_len: usize, aad_out: *mut SolitonBuf, ) -> i32 { if aad_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(aad_out as *mut u8, 0, std::mem::size_of::()) }; if sender_fp.is_null() || recipient_fp.is_null() || session_init_encoded.is_null() { return SolitonError::NullPointer as i32; } check_len!(sender_fp_len, 32); check_len!(recipient_fp_len, 32); // SessionInit encoding: ~3543 bytes without OPK, ~4669 bytes with OPK. // 8 KiB ≈ 1.75× the maximum (~4669); DoS bound only, no structural parse here. if session_init_encoded_len == 0 || session_init_encoded_len > 8192 { return SolitonError::InvalidLength as i32; } // SAFETY: sender_fp and recipient_fp are non-null (checked at function entry) // and validated to be 32 bytes. let sfp = unsafe { &*(sender_fp as *const [u8; 32]) }; let rfp = unsafe { &*(recipient_fp as *const [u8; 32]) }; // SAFETY: session_init_encoded is non-null (checked at function entry); // session_init_encoded_len bounded to 8192 above. let si_encoded = unsafe { slice::from_raw_parts(session_init_encoded, session_init_encoded_len) }; let aad = try_capi!(soliton::kex::build_first_message_aad_from_encoded( sfp, rfp, si_encoded )); unsafe { *aad_out = SolitonBuf::from_vec(aad) }; 0 } /// Encode a session init message for AAD construction (§7.4). /// /// Bob receives a session init's component fields over the wire and needs /// to encode them identically to how Alice encoded them, for AAD matching. /// /// This is a convenience wrapper — Bob can also use the pre-encoded bytes /// from Alice's message directly with `soliton_kex_build_first_message_aad`. /// /// `crypto_version`: null-terminated crypto version string. /// `sender_fp`: sender's IK fingerprint (32 bytes, non-null). /// `recipient_fp`: recipient's IK fingerprint (32 bytes, non-null). /// `sender_ek` / `sender_ek_len`: sender's ephemeral X-Wing public key. /// `ct_ik` / `ct_ik_len`: IK ciphertext. /// `ct_spk` / `ct_spk_len`: SPK ciphertext. /// `spk_id`: signed pre-key ID. /// `ct_opk` / `ct_opk_len`: OPK ciphertext (null if absent). /// `opk_id`: one-time pre-key ID (must be 0 if ct_opk is null; non-zero returns `SOLITON_ERR_INVALID_DATA`). /// `encoded_out`: receives the encoded bytes (caller frees). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_encode_session_init( crypto_version: *const c_char, sender_fp: *const u8, sender_fp_len: usize, recipient_fp: *const u8, recipient_fp_len: usize, sender_ek: *const u8, sender_ek_len: usize, ct_ik: *const u8, ct_ik_len: usize, ct_spk: *const u8, ct_spk_len: usize, spk_id: u32, ct_opk: *const u8, ct_opk_len: usize, opk_id: u32, encoded_out: *mut SolitonBuf, ) -> i32 { if encoded_out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes(encoded_out as *mut u8, 0, std::mem::size_of::()) }; if crypto_version.is_null() || sender_fp.is_null() || recipient_fp.is_null() || sender_ek.is_null() || ct_ik.is_null() || ct_spk.is_null() { return SolitonError::NullPointer as i32; } let cv = match unsafe { cstr_to_str(crypto_version) } { Ok(s) => s, Err(code) => return code, }; check_len!(sender_fp_len, 32); check_len!(recipient_fp_len, 32); // SAFETY: sender_fp / recipient_fp are non-null (checked above) and validated to be 32 bytes. let fp = unsafe { &*(sender_fp as *const [u8; 32]) }; let rfp = unsafe { &*(recipient_fp as *const [u8; 32]) }; check_len!(sender_ek_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); check_len!(ct_ik_len, soliton::constants::XWING_CIPHERTEXT_SIZE); check_len!(ct_spk_len, soliton::constants::XWING_CIPHERTEXT_SIZE); let ek = try_capi!(xwing::PublicKey::from_bytes( unsafe { slice::from_raw_parts(sender_ek, sender_ek_len) }.to_vec() )); let ct_ik_val = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(ct_ik, ct_ik_len) }.to_vec() )); let ct_spk_val = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(ct_spk, ct_spk_len) }.to_vec() )); // Co-presence guard: ct_opk is optional, but null+nonzero or non-null+zero // are invalid — they indicate a caller logic bug. if ct_opk.is_null() && ct_opk_len != 0 { return SolitonError::NullPointer as i32; } // InvalidLength: caller provided a pointer but wrong length. if !ct_opk.is_null() && ct_opk_len != soliton::constants::XWING_CIPHERTEXT_SIZE { return SolitonError::InvalidLength as i32; } let (ct_opk_opt, opk_id_opt) = if ct_opk.is_null() { // Reject non-zero opk_id when ct_opk is absent — a caller logic bug. if opk_id != 0 { return SolitonError::InvalidData as i32; } (None, None) } else { let ct = try_capi!(xwing::Ciphertext::from_bytes( unsafe { slice::from_raw_parts(ct_opk, ct_opk_len) }.to_vec() )); (Some(ct), Some(opk_id)) }; let si = soliton::kex::SessionInit { crypto_version: cv.to_string(), sender_ik_fingerprint: *fp, recipient_ik_fingerprint: *rfp, sender_ek: ek, ct_ik: ct_ik_val, ct_spk: ct_spk_val, spk_id, ct_opk: ct_opk_opt, opk_id: opk_id_opt, }; let encoded = try_capi!(soliton::kex::encode_session_init(&si)); unsafe { *encoded_out = SolitonBuf::from_vec(encoded) }; 0 } /// Decoded session init. Produced by [`soliton_kex_decode_session_init`]. /// /// Free with [`soliton_decoded_session_init_free`] when done. This releases /// the library-allocated `crypto_version` buffer. All other fields are inline. /// /// `ct_opk` and `opk_id` are only valid when `has_opk == 0x01`. #[repr(C)] /// # Stack Size /// /// This struct is ~4.7 KiB due to three inline `[u8; 1120]` ciphertext /// arrays plus a `[u8; 1216]` ephemeral key. Inline arrays avoid heap /// allocation and lifetime management for FFI callers. Binding authors /// targeting small-stack runtimes (e.g., Go goroutines with 8 KiB initial /// stacks) should heap-allocate this struct or use `Box>`. pub struct SolitonDecodedSessionInit { /// UTF-8 crypto version string (library-allocated). pub crypto_version: SolitonBuf, /// SHA3-256(Alice.IK_pub) — 32 bytes, inline. pub sender_fp: [u8; 32], /// SHA3-256(Bob.IK_pub) — 32 bytes, inline. pub recipient_fp: [u8; 32], /// Alice's ephemeral X-Wing public key — 1216 bytes, inline. pub sender_ek: [u8; SOLITON_XWING_PK_SIZE], /// X-Wing ciphertext to Bob's IK — 1120 bytes, inline. pub ct_ik: [u8; SOLITON_XWING_CT_SIZE], /// X-Wing ciphertext to Bob's SPK — 1120 bytes, inline. pub ct_spk: [u8; SOLITON_XWING_CT_SIZE], /// Bob's signed pre-key ID. pub spk_id: u32, /// 0x01 if a one-time pre-key was used; 0x00 otherwise. pub has_opk: u8, /// X-Wing ciphertext to Bob's OPK — 1120 bytes, inline. Valid only when `has_opk == 0x01`. /// /// `[u8; N]` has alignment 1, so this field directly follows `has_opk` /// with no padding between them. pub ct_opk: [u8; SOLITON_XWING_CT_SIZE], /// Bob's one-time pre-key ID. Valid only when `has_opk == 0x01`. /// /// `#[repr(C)]` inserts 3 bytes of padding between `ct_opk` and this /// `u32` field to satisfy its 4-byte alignment requirement. The struct is /// zero-filled by `soliton_kex_decode_session_init` before population, so /// padding bytes are always zeroed. pub opk_id: u32, } /// Decode a session init from the binary wire format produced by /// [`soliton_kex_encode_session_init`]. /// /// `encoded` / `encoded_len`: the wire bytes received from Alice (non-null; /// maximum 64 KiB / 65536 bytes). /// `out`: receives the decoded fields (non-null). Zeroed on error return. /// /// On success, the caller must call [`soliton_decoded_session_init_free`] /// on `out` when done to release the library-allocated `crypto_version` buffer. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_kex_decode_session_init( encoded: *const u8, encoded_len: usize, out: *mut SolitonDecodedSessionInit, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { std::ptr::write_bytes( out as *mut u8, 0, std::mem::size_of::(), ) }; if encoded.is_null() { return SolitonError::NullPointer as i32; } if encoded_len == 0 { return SolitonError::InvalidLength as i32; } // SessionInit is structurally bounded (~5 KiB). 64 KiB is generous // but prevents allocation amplification from wildly oversized input. const MAX_ENCODED_LEN: usize = 64 * 1024; if encoded_len > MAX_ENCODED_LEN { return SolitonError::InvalidLength as i32; } let data = unsafe { slice::from_raw_parts(encoded, encoded_len) }; let si = try_capi!(soliton::kex::decode_session_init(data)); let o = unsafe { &mut *out }; // Null-terminate for C string compatibility (consistent with other // string-returning CAPI functions like verification_phrase). let mut cv_bytes = si.crypto_version.into_bytes(); cv_bytes.push(0); o.crypto_version = SolitonBuf::from_vec(cv_bytes); o.sender_fp = si.sender_ik_fingerprint; o.recipient_fp = si.recipient_ik_fingerprint; o.sender_ek.copy_from_slice(si.sender_ek.as_bytes()); o.ct_ik.copy_from_slice(si.ct_ik.as_bytes()); o.ct_spk.copy_from_slice(si.ct_spk.as_bytes()); o.spk_id = si.spk_id; if let (Some(ct_opk), Some(opk_id)) = (si.ct_opk, si.opk_id) { o.has_opk = 0x01; o.ct_opk.copy_from_slice(ct_opk.as_bytes()); o.opk_id = opk_id; } else { o.has_opk = 0x00; } 0 } /// Free a decoded session init's library-allocated fields. /// /// `out`: pointer to a `SolitonDecodedSessionInit` previously populated by /// [`soliton_kex_decode_session_init`]. Frees `crypto_version` via /// `soliton_buf_free`. Safe to call with a null pointer (no-op). /// After this call, `crypto_version.ptr` is null and `crypto_version.len` is 0. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_decoded_session_init_free(out: *mut SolitonDecodedSessionInit) { if out.is_null() { return; } unsafe { soliton_buf_free(&mut (*out).crypto_version) }; } // ═══════════════════════════════════════════════════════════════════════ // X-Wing: keygen, encapsulate, decapsulate // ═══════════════════════════════════════════════════════════════════════ /// Generate an X-Wing keypair. /// /// `pk_out`: receives the public key (1216 bytes, caller frees). /// `sk_out`: receives the secret key (2432 bytes, caller frees). /// /// # Security /// /// `sk_out` contains the raw X-Wing secret key. Free it with /// `soliton_buf_free`, NOT `free()`. `soliton_buf_free` zeroizes /// the buffer contents before releasing memory; `free()` does not. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_xwing_keygen( pk_out: *mut SolitonBuf, sk_out: *mut SolitonBuf, ) -> i32 { if pk_out.is_null() || sk_out.is_null() { return SolitonError::NullPointer as i32; } // Zero outputs so error paths leave them in a safe, freeable state. unsafe { std::ptr::write_bytes(pk_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(sk_out as *mut u8, 0, std::mem::size_of::()); } match xwing::keygen() { Ok((pk, sk)) => { unsafe { *pk_out = SolitonBuf::from_vec(pk.as_bytes().to_vec()); *sk_out = SolitonBuf::from_vec(sk.as_bytes().to_vec()); } 0 } Err(e) => error_to_code(e), } } /// Encapsulate to an X-Wing public key. /// /// `pk` / `pk_len`: X-Wing public key (1216 bytes). /// `ct_out`: receives the ciphertext (1120 bytes, caller frees). /// `ss_out` / `ss_out_len`: must point to exactly 32 bytes for the shared secret. /// On error, `ss_out` is zeroed. Caller must check return code before using. /// /// # Security /// /// The 32-byte shared secret written to `ss_out` is raw key material. The /// caller must zeroize `ss_out` when it is no longer needed (e.g., /// `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_xwing_encapsulate( pk: *const u8, pk_len: usize, ct_out: *mut SolitonBuf, ss_out: *mut u8, ss_out_len: usize, ) -> i32 { if ct_out.is_null() || ss_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(ss_out_len, 32); unsafe { std::ptr::write_bytes(ct_out as *mut u8, 0, std::mem::size_of::()); std::ptr::write_bytes(ss_out, 0, 32); } if pk.is_null() { return SolitonError::NullPointer as i32; } check_len!(pk_len, soliton::constants::XWING_PUBLIC_KEY_SIZE); let pk_bytes = unsafe { slice::from_raw_parts(pk, pk_len) }; let public_key = try_capi!(xwing::PublicKey::from_bytes(pk_bytes.to_vec())); match xwing::encapsulate(&public_key) { Ok((ct, ss)) => { unsafe { *ct_out = SolitonBuf::from_vec(ct.as_bytes().to_vec()); std::ptr::copy_nonoverlapping(ss.as_bytes().as_ptr(), ss_out, 32); } 0 } Err(e) => error_to_code(e), } } /// Decapsulate an X-Wing ciphertext. /// /// `sk` / `sk_len`: X-Wing secret key. /// `ct` / `ct_len`: X-Wing ciphertext (1120 bytes). /// `ss_out` / `ss_out_len`: must point to exactly 32 bytes for the shared secret. /// On error, `ss_out` is zeroed. Caller must check return code before using. /// /// # Security /// /// The 32-byte shared secret written to `ss_out` is raw key material. The /// caller must zeroize `ss_out` when it is no longer needed (e.g., /// `explicit_bzero` on POSIX, `SecureZeroMemory` on Windows). /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_xwing_decapsulate( sk: *const u8, sk_len: usize, ct: *const u8, ct_len: usize, ss_out: *mut u8, ss_out_len: usize, ) -> i32 { if ss_out.is_null() { return SolitonError::NullPointer as i32; } check_len!(ss_out_len, 32); unsafe { std::ptr::write_bytes(ss_out, 0, 32) }; if sk.is_null() || ct.is_null() { return SolitonError::NullPointer as i32; } check_len!(sk_len, soliton::constants::XWING_SECRET_KEY_SIZE); check_len!(ct_len, soliton::constants::XWING_CIPHERTEXT_SIZE); let sk_bytes = unsafe { slice::from_raw_parts(sk, sk_len) }; let ct_bytes = unsafe { slice::from_raw_parts(ct, ct_len) }; let secret_key = try_capi!(xwing::SecretKey::from_bytes(sk_bytes.to_vec())); let ciphertext = try_capi!(xwing::Ciphertext::from_bytes(ct_bytes.to_vec())); match xwing::decapsulate(&secret_key, &ciphertext) { Ok(ss) => { unsafe { std::ptr::copy_nonoverlapping(ss.as_bytes().as_ptr(), ss_out, 32) }; 0 } Err(e) => error_to_code(e), } } // ═══════════════════════════════════════════════════════════════════════ // Call: key derivation + intra-call rekeying // ═══════════════════════════════════════════════════════════════════════ /// Opaque call keys state. /// /// # Undefined Behavior /// /// **Do not duplicate handles.** Copying the pointer and using both copies /// produces inconsistent state — both aliases share a `step_count` counter, /// so sequential `advance` calls via different aliases desynchronize key /// material. Freeing one alias invalidates the other (use-after-free). The /// reentrancy guard only prevents *concurrent* access, not *sequential* misuse. pub struct SolitonCallKeys { magic: u32, inner: soliton::call::CallKeys, in_use: AtomicBool, } /// Derive call encryption keys from a ratchet session's root key and an /// ephemeral KEM shared secret. /// /// `ratchet`: opaque ratchet state (read-only — not modified). /// `kem_ss`: 32-byte ephemeral X-Wing shared secret from call signaling. /// `call_id`: 16-byte random call identifier (generated by the initiator). /// `out`: receives an opaque `SolitonCallKeys*` (caller frees with /// `soliton_call_keys_free`). /// /// Fingerprints are taken from the ratchet state (set at init time), not /// passed as parameters. /// /// # Security /// /// The derived keys depend on the ratchet's current root key, which changes /// after every KEM ratchet step. If ratchet messages are exchanged between /// call-offer and call-answer, the two parties' root keys will differ and /// the call keys will not match (manifesting as AEAD failure on the first /// audio packet). Callers should ensure both parties derive call keys from /// the same ratchet epoch. /// /// The returned `SolitonCallKeys` contains call encryption key material. /// Free it with `soliton_call_keys_free` when the call ends — this /// zeroizes all keys. Do NOT use `free()` directly. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): any pointer argument is null. /// - `SOLITON_ERR_INVALID_DATA` (-17): magic check failed, ratchet is dead /// (all-zero root key — post-reset session), `kem_ss` is all-zero, or /// `call_id` is all-zero. /// - `SOLITON_ERR_INVALID_LENGTH` (-1): `kem_ss_len` is not 32, or `call_id_len` /// is not 16. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): ratchet handle is in use by another call. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_ratchet_derive_call_keys( ratchet: *const SolitonRatchet, kem_ss: *const u8, kem_ss_len: usize, call_id: *const u8, call_id_len: usize, out: *mut *mut SolitonCallKeys, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut(); } if ratchet.is_null() || kem_ss.is_null() || call_id.is_null() { return SolitonError::NullPointer as i32; } check_len!(kem_ss_len, 32); check_len!(call_id_len, 16); let _guard = acquire_guard!(unsafe { &*ratchet }, MAGIC_RATCHET); // SAFETY: ratchet is non-null (checked above) and was created by // soliton_ratchet_init_alice/bob, guaranteeing valid alignment and // lifetime. let r = unsafe { &*ratchet }; // SAFETY: kem_ss is non-null (checked above) and validated to be 32 bytes. let ss = unsafe { &*(kem_ss as *const [u8; 32]) }; // SAFETY: call_id is non-null (checked above) and validated to be 16 bytes. let cid = unsafe { &*(call_id as *const [u8; 16]) }; let keys = try_capi!(r.inner.derive_call_keys(ss, cid)); unsafe { *out = Box::into_raw(Box::new(SolitonCallKeys { magic: MAGIC_CALLKEYS, inner: keys, in_use: AtomicBool::new(false), })); } 0 } /// Copy the current send key (32 bytes) to a caller-allocated buffer. /// /// `keys`: opaque call keys state. /// `out` / `out_len`: caller-allocated buffer. `out_len` must be exactly 32. /// /// # Security /// /// The caller must zeroize `out` when the key is no longer needed. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_call_keys_send_key( keys: *const SolitonCallKeys, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } check_len!(out_len, 32); // Zero the caller's buffer — prevents stale key material from a prior // call surviving on error paths. Placed after check_len! so out_len is // validated before use as a write bound. unsafe { std::ptr::write_bytes(out, 0, 32); } if keys.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keys }, MAGIC_CALLKEYS); let k = unsafe { &*keys }; unsafe { std::ptr::copy_nonoverlapping(k.inner.send_key().as_ptr(), out, 32); } 0 } /// Copy the current recv key (32 bytes) to a caller-allocated buffer. /// /// `keys`: opaque call keys state. /// `out` / `out_len`: caller-allocated buffer. `out_len` must be exactly 32. /// /// # Security /// /// The caller must zeroize `out` when the key is no longer needed. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_call_keys_recv_key( keys: *const SolitonCallKeys, out: *mut u8, out_len: usize, ) -> i32 { if out.is_null() { return SolitonError::NullPointer as i32; } check_len!(out_len, 32); // Zero the caller's buffer — prevents stale key material from a prior // call surviving on error paths. Placed after check_len! so out_len is // validated before use as a write bound. unsafe { std::ptr::write_bytes(out, 0, 32); } if keys.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keys }, MAGIC_CALLKEYS); let k = unsafe { &*keys }; unsafe { std::ptr::copy_nonoverlapping(k.inner.recv_key().as_ptr(), out, 32); } 0 } /// Advance the call chain, replacing the current call keys with fresh /// key material derived from the internal chain key. /// /// After this call, `soliton_call_keys_send_key` and /// `soliton_call_keys_recv_key` return the new keys. The old keys /// and chain key are zeroized. /// /// # Possible Errors /// /// - `SOLITON_ERR_NULL_POINTER` (-13): `keys` is null. /// - `SOLITON_ERR_CHAIN_EXHAUSTED` (-15): advance count has reached /// `MAX_CALL_ADVANCE`. All keys are zeroized — the `CallKeys` handle /// is dead and must be freed. /// - `SOLITON_ERR_CONCURRENT_ACCESS` (-18): handle is in use by another call. /// /// Returns 0 on success, negative on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_call_keys_advance(keys: *mut SolitonCallKeys) -> i32 { if keys.is_null() { return SolitonError::NullPointer as i32; } let _guard = acquire_guard!(unsafe { &*keys }, MAGIC_CALLKEYS); let k = unsafe { &mut *keys }; try_capi!(k.inner.advance()); 0 } /// Free a `SolitonCallKeys` object, zeroizing all key material. /// /// `keys`: pointer to the caller's `SolitonCallKeys*` variable. After /// freeing, the caller's pointer is set to NULL, making double-free a no-op. /// Null outer pointer and null inner pointer are both no-ops (returns 0). /// /// Returns 0 on success, `SOLITON_ERR_CONCURRENT_ACCESS` (-18) if the handle /// is currently in use. See `soliton_ratchet_free` for retry semantics. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_call_keys_free(keys: *mut *mut SolitonCallKeys) -> i32 { if keys.is_null() { return 0; } let inner = unsafe { *keys }; if inner.is_null() { return 0; } if unsafe { &*inner }.magic != MAGIC_CALLKEYS { return SolitonError::InvalidData as i32; } // See soliton_ratchet_free for CAS rationale. if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } // SAFETY: inner was allocated via Box::into_raw in // soliton_ratchet_derive_call_keys. ZeroizeOnDrop fires on // CallKeys, zeroizing all key material before deallocation. unsafe { drop(Box::from_raw(inner)) }; // Null the caller's pointer — subsequent free calls are no-ops. unsafe { *keys = std::ptr::null_mut() }; 0 } // ═══════════════════════════════════════════════════════════════════════════ // Streaming AEAD // ═══════════════════════════════════════════════════════════════════════════ // ── Magic constants ────────────────────────────────────────────────────── // 7 bits Hamming distance from STRE<>SDEC, 7+ from existing handles. const MAGIC_STREAM_ENC: u32 = 0x5354_5245; // "STRE" const MAGIC_STREAM_DEC: u32 = 0x5344_4543; // "SDEC" /// Maximum caller-supplied AAD size (256 MiB), matching existing CAPI /// convention for variable-length metadata inputs. const MAX_AAD_LEN: usize = 256 * 1024 * 1024; /// Opaque streaming encryptor handle. pub struct SolitonStreamEncryptor { magic: u32, inner: soliton::streaming::StreamEncryptor, in_use: AtomicBool, } /// Opaque streaming decryptor handle. pub struct SolitonStreamDecryptor { magic: u32, inner: soliton::streaming::StreamDecryptor, in_use: AtomicBool, } /// Initialize a streaming encryptor. /// /// `key`: 32-byte encryption key. Copied internally; caller may zeroize after. /// `aad`: optional caller-supplied AAD context. NULL with aad_len=0 for none. /// `compress`: if true, each chunk is zstd-compressed before encryption. /// `out`: receives the allocated handle. Caller frees via /// `soliton_stream_encrypt_free`. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_init( key: *const u8, key_len: usize, aad: *const u8, aad_len: usize, compress: bool, out: *mut *mut SolitonStreamEncryptor, ) -> i32 { if key.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut() }; if aad.is_null() && aad_len > 0 { return SolitonError::NullPointer as i32; } check_len!(key_len, 32); if aad_len > MAX_AAD_LEN { return SolitonError::InvalidLength as i32; } let key_slice: &[u8; 32] = unsafe { &*(key as *const [u8; 32]) }; let aad_slice = if aad_len > 0 { unsafe { slice::from_raw_parts(aad, aad_len) } } else { &[] }; let enc = try_capi!(soliton::streaming::stream_encrypt_init( key_slice, aad_slice, compress )); let handle = Box::new(SolitonStreamEncryptor { magic: MAGIC_STREAM_ENC, inner: enc, in_use: AtomicBool::new(false), }); unsafe { *out = Box::into_raw(handle) }; 0 } /// Copy the 26-byte header into a caller-allocated buffer. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_header( enc: *const SolitonStreamEncryptor, out: *mut u8, out_len: usize, ) -> i32 { if enc.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } let handle = unsafe { &*enc }; let _guard = acquire_guard!(handle, MAGIC_STREAM_ENC); if out_len < soliton::constants::STREAM_HEADER_SIZE { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } let header = handle.inner.header(); unsafe { std::ptr::copy_nonoverlapping(header.as_ptr(), out, header.len()); } 0 } /// Encrypt one chunk into a caller-allocated buffer. /// /// `out_len` must be >= `SOLITON_STREAM_ENCRYPT_MAX`. /// `out_written` receives actual bytes written. Set to 0 on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_chunk( enc: *mut SolitonStreamEncryptor, plaintext: *const u8, plaintext_len: usize, is_last: bool, out: *mut u8, out_len: usize, out_written: *mut usize, ) -> i32 { if enc.is_null() || out.is_null() || out_written.is_null() { return SolitonError::NullPointer as i32; } if plaintext.is_null() && plaintext_len > 0 { return SolitonError::NullPointer as i32; } // Zero output field before acquiring the guard so that all error paths // (including guard early-returns) leave *out_written in the documented // "0 on error" state. unsafe { *out_written = 0 }; let handle = unsafe { &mut *enc }; let _guard = acquire_guard!(handle, MAGIC_STREAM_ENC); if out_len < soliton::constants::STREAM_ENCRYPT_MAX { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } let pt_slice = if plaintext_len > 0 { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } } else { &[] }; let chunk = match handle.inner.encrypt_chunk(pt_slice, is_last) { Ok(c) => c, Err(e) => { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return error_to_code(e); } }; unsafe { std::ptr::copy_nonoverlapping(chunk.as_ptr(), out, chunk.len()); *out_written = chunk.len(); } 0 } /// Encrypt a specific chunk by index (random access). /// /// Stateless: does not advance the sequential counter, does not set the /// `finalized` flag, and does not enforce the post-finalization guard. /// The caller is responsible for assigning unique indices and identifying /// the final chunk. /// /// Enables parallel chunk encryption. Because nonces and AAD are /// index-derived, independent chunks may be dispatched concurrently — /// the CAPI reentrancy guard (§13.6) prevents concurrent calls on the same /// handle, so parallel encryption requires one encryptor handle per thread. /// /// # Parameters /// /// - `enc`: encryptor handle (const — no state mutation); must not be NULL. /// - `index`: chunk index to encrypt at. /// - `plaintext`: plaintext bytes; may be NULL if `plaintext_len == 0`. /// - `plaintext_len`: length of plaintext. Must be exactly /// `SOLITON_STREAM_CHUNK_SIZE` for non-final chunks; /// `0..=SOLITON_STREAM_CHUNK_SIZE` for the final chunk. /// - `is_last`: `true` if this is the final chunk of the stream. /// - `out`: caller-allocated output buffer; must not be NULL. /// - `out_len`: size of `out` in bytes; must be >= `SOLITON_STREAM_ENCRYPT_MAX`. /// - `out_written`: receives the actual bytes written; set to 0 on error. /// /// # Returns /// /// 0 on success; negative `SolitonError` code on failure. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_chunk_at( enc: *const SolitonStreamEncryptor, index: u64, plaintext: *const u8, plaintext_len: usize, is_last: bool, out: *mut u8, out_len: usize, out_written: *mut usize, ) -> i32 { if enc.is_null() || out.is_null() || out_written.is_null() { return SolitonError::NullPointer as i32; } if plaintext.is_null() && plaintext_len > 0 { return SolitonError::NullPointer as i32; } // Zero output field before acquiring the guard so that all error paths // (including guard early-returns) leave *out_written in the documented // "0 on error" state. unsafe { *out_written = 0 }; let handle = unsafe { &*enc }; let _guard = acquire_guard!(handle, MAGIC_STREAM_ENC); if out_len < soliton::constants::STREAM_ENCRYPT_MAX { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } let pt_slice = if plaintext_len > 0 { unsafe { slice::from_raw_parts(plaintext, plaintext_len) } } else { &[] }; let chunk = match handle.inner.encrypt_chunk_at(index, is_last, pt_slice) { Ok(c) => c, Err(e) => { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return error_to_code(e); } }; unsafe { std::ptr::copy_nonoverlapping(chunk.as_ptr(), out, chunk.len()); *out_written = chunk.len(); } 0 } /// Return whether the encryptor has been finalized. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_is_finalized( enc: *const SolitonStreamEncryptor, out: *mut bool, ) -> i32 { if enc.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } let handle = unsafe { &*enc }; let _guard = acquire_guard!(handle, MAGIC_STREAM_ENC); unsafe { *out = handle.inner.is_finalized() }; 0 } /// Free the streaming encryptor. Sets `*enc` to NULL after freeing. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_encrypt_free(enc: *mut *mut SolitonStreamEncryptor) -> i32 { if enc.is_null() { return 0; } let inner = unsafe { *enc }; if inner.is_null() { return 0; } if unsafe { &*inner }.magic != MAGIC_STREAM_ENC { return SolitonError::InvalidData as i32; } // Check reentrancy — do not free a handle that is in use. if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } // Key material in StreamEncryptor.key is Zeroizing — zeroized on drop. unsafe { drop(Box::from_raw(inner)) }; unsafe { *enc = std::ptr::null_mut() }; 0 } /// Initialize a streaming decryptor from the 26-byte header. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_init( key: *const u8, key_len: usize, header: *const u8, header_len: usize, aad: *const u8, aad_len: usize, out: *mut *mut SolitonStreamDecryptor, ) -> i32 { if key.is_null() || header.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } unsafe { *out = std::ptr::null_mut() }; if aad.is_null() && aad_len > 0 { return SolitonError::NullPointer as i32; } check_len!(key_len, 32); check_len!(header_len, soliton::constants::STREAM_HEADER_SIZE); if aad_len > MAX_AAD_LEN { return SolitonError::InvalidLength as i32; } let key_slice: &[u8; 32] = unsafe { &*(key as *const [u8; 32]) }; let header_slice: &[u8; soliton::constants::STREAM_HEADER_SIZE] = unsafe { &*(header as *const [u8; soliton::constants::STREAM_HEADER_SIZE]) }; let aad_slice = if aad_len > 0 { unsafe { slice::from_raw_parts(aad, aad_len) } } else { &[] }; let dec = try_capi!(soliton::streaming::stream_decrypt_init( key_slice, header_slice, aad_slice )); let handle = Box::new(SolitonStreamDecryptor { magic: MAGIC_STREAM_DEC, inner: dec, in_use: AtomicBool::new(false), }); unsafe { *out = Box::into_raw(handle) }; 0 } /// Decrypt next sequential chunk into a caller-allocated buffer. /// /// `out_len` must be >= `SOLITON_STREAM_CHUNK_SIZE`. /// `out_written` receives actual plaintext bytes. Set to 0 on error. /// `is_last` set to true if this was the final chunk. Set to false on error. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_chunk( dec: *mut SolitonStreamDecryptor, chunk: *const u8, chunk_len: usize, out: *mut u8, out_len: usize, out_written: *mut usize, is_last: *mut bool, ) -> i32 { if dec.is_null() || chunk.is_null() || out.is_null() || out_written.is_null() || is_last.is_null() { return SolitonError::NullPointer as i32; } // Zero output fields before acquiring the guard so that all error paths // (including guard early-returns) leave outputs in the documented // "0 on error" / "false on error" state. unsafe { *out_written = 0; *is_last = false; } let handle = unsafe { &mut *dec }; let _guard = acquire_guard!(handle, MAGIC_STREAM_DEC); if out_len < soliton::constants::STREAM_CHUNK_SIZE { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } let chunk_slice = unsafe { slice::from_raw_parts(chunk, chunk_len) }; let (plaintext, last) = match handle.inner.decrypt_chunk(chunk_slice) { Ok(r) => r, Err(e) => { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return error_to_code(e); } }; unsafe { std::ptr::copy_nonoverlapping(plaintext.as_ptr(), out, plaintext.len()); *out_written = plaintext.len(); *is_last = last; } 0 } /// Decrypt a specific chunk by index (random access). /// /// Does not advance the sequential counter. Can be called after finalization. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_chunk_at( dec: *const SolitonStreamDecryptor, index: u64, chunk: *const u8, chunk_len: usize, out: *mut u8, out_len: usize, out_written: *mut usize, is_last: *mut bool, ) -> i32 { if dec.is_null() || chunk.is_null() || out.is_null() || out_written.is_null() || is_last.is_null() { return SolitonError::NullPointer as i32; } // Zero output fields before acquiring the guard so that all error paths // (including guard early-returns) leave outputs in the documented // "0 on error" / "false on error" state. unsafe { *out_written = 0; *is_last = false; } let handle = unsafe { &*dec }; let _guard = acquire_guard!(handle, MAGIC_STREAM_DEC); if out_len < soliton::constants::STREAM_CHUNK_SIZE { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return SolitonError::InvalidLength as i32; } let chunk_slice = unsafe { slice::from_raw_parts(chunk, chunk_len) }; let (plaintext, last) = match handle.inner.decrypt_chunk_at(index, chunk_slice) { Ok(r) => r, Err(e) => { unsafe { std::ptr::write_bytes(out, 0, out_len) }; return error_to_code(e); } }; unsafe { std::ptr::copy_nonoverlapping(plaintext.as_ptr(), out, plaintext.len()); *out_written = plaintext.len(); *is_last = last; } 0 } /// Return whether the decryptor has been finalized. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_is_finalized( dec: *const SolitonStreamDecryptor, out: *mut bool, ) -> i32 { if dec.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } let handle = unsafe { &*dec }; let _guard = acquire_guard!(handle, MAGIC_STREAM_DEC); unsafe { *out = handle.inner.is_finalized() }; 0 } /// Return the next expected sequential chunk index. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_expected_index( dec: *const SolitonStreamDecryptor, out: *mut u64, ) -> i32 { if dec.is_null() || out.is_null() { return SolitonError::NullPointer as i32; } let handle = unsafe { &*dec }; let _guard = acquire_guard!(handle, MAGIC_STREAM_DEC); unsafe { *out = handle.inner.expected_index() }; 0 } /// Free the streaming decryptor. Sets `*dec` to NULL after freeing. #[unsafe(no_mangle)] pub unsafe extern "C" fn soliton_stream_decrypt_free(dec: *mut *mut SolitonStreamDecryptor) -> i32 { if dec.is_null() { return 0; } let inner = unsafe { *dec }; if inner.is_null() { return 0; } if unsafe { &*inner }.magic != MAGIC_STREAM_DEC { return SolitonError::InvalidData as i32; } if unsafe { &*inner } .in_use .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) .is_err() { return SolitonError::ConcurrentAccess as i32; } unsafe { drop(Box::from_raw(inner)) }; unsafe { *dec = std::ptr::null_mut() }; 0 } #[cfg(test)] mod tests { use super::*; #[test] fn error_to_code_maps_all_variants() { use soliton::error::Error; // Verify every Error variant maps to its documented CAPI error code. assert_eq!( error_to_code(Error::InvalidLength { expected: 32, got: 0 }), -1 ); assert_eq!(error_to_code(Error::DecapsulationFailed), -2); assert_eq!(error_to_code(Error::VerificationFailed), -3); assert_eq!(error_to_code(Error::AeadFailed), -4); assert_eq!(error_to_code(Error::BundleVerificationFailed), -5); assert_eq!(error_to_code(Error::TooManySkipped), -6); assert_eq!(error_to_code(Error::DuplicateMessage), -7); assert_eq!(error_to_code(Error::AlgorithmDisabled), -9); assert_eq!(error_to_code(Error::UnsupportedVersion), -10); assert_eq!(error_to_code(Error::DecompressionFailed), -11); assert_eq!(error_to_code(Error::UnsupportedFlags), -14); assert_eq!(error_to_code(Error::ChainExhausted), -15); assert_eq!(error_to_code(Error::UnsupportedCryptoVersion), -16); assert_eq!(error_to_code(Error::InvalidData), -17); assert_eq!(error_to_code(Error::Internal), -12); } #[test] fn reentrancy_guard_acquire_release() { let flag = AtomicBool::new(false); // First acquire succeeds. let guard = ReentrancyGuard::acquire(&flag).expect("first acquire must succeed"); assert!(flag.load(Ordering::SeqCst), "flag must be true while held"); // Second acquire fails (concurrent access detected). assert!( ReentrancyGuard::acquire(&flag).is_err(), "second acquire must fail while first is held" ); // Drop releases the guard. drop(guard); assert!( !flag.load(Ordering::SeqCst), "flag must be false after drop" ); // Re-acquire after release succeeds. let _guard2 = ReentrancyGuard::acquire(&flag).expect("re-acquire must succeed after release"); } }