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
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
4945 lines
193 KiB
Rust
4945 lines
193 KiB
Rust
//! 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");
|
||
}
|
||
}
|