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

4945 lines
193 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<Self, i32> {
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<Void>).
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::<u64>();
/// 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<u8>` 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<u8>) -> 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<Vec<u8>>. 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::<SolitonBuf>());
std::ptr::write_bytes(sk_out as *mut u8, 0, std::mem::size_of::<SolitonBuf>());
std::ptr::write_bytes(
fingerprint_hex_out as *mut u8,
0,
std::mem::size_of::<SolitonBuf>(),
);
}
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::<SolitonBuf>()) };
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::<SolitonBuf>());
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::<SolitonBuf>());
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<Void>).
// 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::<SolitonEncryptedMessage>(),
)
};
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::<SolitonBuf>(),
)
};
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::<SolitonBuf>());
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::<SolitonBuf>(),
);
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::<SolitonBuf>()) };
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::<SolitonBuf>(),
)
};
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::<SolitonBuf>()) };
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::<SolitonBuf>(),
)
};
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::<SolitonBuf>(),
)
};
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::<SolitonBuf>(),
)
};
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::<SolitonBuf>()) };
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::<SolitonInitiatedSession>(),
)
};
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::<SolitonReceivedSession>(),
);
}
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::<SolitonBuf>()) };
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::<SolitonBuf>()) };
// 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<u8> = 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::<SolitonBuf>()) };
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::<SolitonBuf>()) };
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<MaybeUninit<...>>`.
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::<SolitonDecodedSessionInit>(),
)
};
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::<SolitonBuf>());
std::ptr::write_bytes(sk_out as *mut u8, 0, std::mem::size_of::<SolitonBuf>());
}
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::<SolitonBuf>());
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");
}
}