initial commit
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>
This commit is contained in:
Kamal Tufekcic 2026-04-02 23:48:10 +03:00
commit 1d99048c95
No known key found for this signature in database
165830 changed files with 79062 additions and 0 deletions

46
soliton_wasm/src/auth.rs Normal file
View file

@ -0,0 +1,46 @@
//! LO-Auth: zero-knowledge authentication via KEM challenge-response.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Generate an auth challenge. Returns { ciphertext: Uint8Array, token: Uint8Array }.
#[wasm_bindgen(js_name = "authChallenge")]
pub fn auth_challenge(client_pk: &[u8]) -> Result<JsValue, JsValue> {
let pk =
soliton::identity::IdentityPublicKey::from_bytes(client_pk.to_vec()).map_err(to_js_err)?;
let (ct, token) = soliton::auth::auth_challenge(&pk).map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"ciphertext".into(),
&js_sys::Uint8Array::from(ct.as_bytes()),
)?;
js_sys::Reflect::set(
&obj,
&"token".into(),
&js_sys::Uint8Array::from(&*token as &[u8]),
)?;
Ok(obj.into())
}
/// Respond to an auth challenge. Returns 32-byte proof.
#[wasm_bindgen(js_name = "authRespond")]
pub fn auth_respond(client_sk: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
let sk =
soliton::identity::IdentitySecretKey::from_bytes(client_sk.to_vec()).map_err(to_js_err)?;
let ct = soliton::primitives::xwing::Ciphertext::from_bytes(ciphertext.to_vec())
.map_err(to_js_err)?;
let proof = soliton::auth::auth_respond(&sk, &ct).map_err(to_js_err)?;
Ok(proof.to_vec())
}
/// Verify an auth proof (constant-time). Returns true if valid.
#[wasm_bindgen(js_name = "authVerify")]
pub fn auth_verify(expected_token: &[u8], proof: &[u8]) -> Result<bool, JsValue> {
if expected_token.len() != 32 || proof.len() != 32 {
return Err(crate::errors::js_error("both inputs must be 32 bytes"));
}
let a: &[u8; 32] = expected_token.try_into().unwrap();
let b: &[u8; 32] = proof.try_into().unwrap();
Ok(soliton::auth::auth_verify(a, b))
}

54
soliton_wasm/src/call.rs Normal file
View file

@ -0,0 +1,54 @@
//! Call key derivation.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Call keys for encrypted voice/video media.
///
/// Call `free()` when done to zeroize key material.
#[wasm_bindgen]
pub struct CallKeys {
inner: Option<soliton::call::CallKeys>,
}
#[wasm_bindgen]
impl CallKeys {
/// Current send key (32 bytes).
#[wasm_bindgen(js_name = "sendKey")]
pub fn send_key(&self) -> Result<Vec<u8>, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("call keys consumed"))?;
Ok(inner.send_key().to_vec())
}
/// Current recv key (32 bytes).
#[wasm_bindgen(js_name = "recvKey")]
pub fn recv_key(&self) -> Result<Vec<u8>, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("call keys consumed"))?;
Ok(inner.recv_key().to_vec())
}
/// Advance the call chain — derives fresh keys, zeroizes old ones.
pub fn advance(&mut self) -> Result<(), JsValue> {
let inner = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("call keys consumed"))?;
inner.advance().map_err(to_js_err)
}
pub fn free(&mut self) {
self.inner = None;
}
}
impl CallKeys {
pub fn from_inner(inner: soliton::call::CallKeys) -> Self {
Self { inner: Some(inner) }
}
}

View file

@ -0,0 +1,16 @@
//! Error mapping from soliton::error::Error to JsValue.
use wasm_bindgen::JsValue;
/// Convert a soliton error to a JS `Error` object.
pub fn to_js_err(e: soliton::error::Error) -> JsValue {
js_sys::Error::new(&e.to_string()).into()
}
/// Create a JS `Error` from a string message.
/// Use this instead of `JsValue::from_str()` so that thrown values are
/// proper `Error` instances — `instanceof Error`, `.message`, and `.stack`
/// all work as JS callers expect.
pub fn js_error(msg: &str) -> JsValue {
js_sys::Error::new(msg).into()
}

View file

@ -0,0 +1,102 @@
//! Identity key management.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// An identity keypair (X-Wing + Ed25519 + ML-DSA-65).
///
/// Call `free()` when done to zeroize secret key material.
#[wasm_bindgen]
pub struct Identity {
pk: soliton::identity::IdentityPublicKey,
sk: Option<soliton::identity::IdentitySecretKey>,
}
#[wasm_bindgen]
impl Identity {
/// Generate a fresh identity keypair.
#[wasm_bindgen(constructor)]
pub fn generate() -> Result<Identity, JsValue> {
let id = soliton::identity::generate_identity().map_err(to_js_err)?;
Ok(Self {
pk: id.public_key,
sk: Some(id.secret_key),
})
}
/// Reconstruct from serialized public + secret key bytes.
#[wasm_bindgen(js_name = "fromBytes")]
pub fn from_bytes(pk_bytes: &[u8], sk_bytes: &[u8]) -> Result<Identity, JsValue> {
let pk = soliton::identity::IdentityPublicKey::from_bytes(pk_bytes.to_vec())
.map_err(to_js_err)?;
let sk = soliton::identity::IdentitySecretKey::from_bytes(sk_bytes.to_vec())
.map_err(to_js_err)?;
Ok(Self { pk, sk: Some(sk) })
}
/// Reconstruct a public-key-only identity (cannot sign).
#[wasm_bindgen(js_name = "fromPublicBytes")]
pub fn from_public_bytes(pk_bytes: &[u8]) -> Result<Identity, JsValue> {
let pk = soliton::identity::IdentityPublicKey::from_bytes(pk_bytes.to_vec())
.map_err(to_js_err)?;
Ok(Self { pk, sk: None })
}
/// Public key bytes (3200 bytes).
#[wasm_bindgen(js_name = "publicKey")]
pub fn public_key(&self) -> Vec<u8> {
self.pk.as_bytes().to_vec()
}
/// Secret key bytes (2496 bytes). Throws if this is a public-key-only identity.
#[wasm_bindgen(js_name = "secretKey")]
pub fn secret_key(&self) -> Result<Vec<u8>, JsValue> {
let sk = self
.sk
.as_ref()
.ok_or_else(|| crate::errors::js_error("no secret key"))?;
Ok(sk.as_bytes().to_vec())
}
/// SHA3-256 fingerprint of the public key (32 bytes).
pub fn fingerprint(&self) -> Vec<u8> {
soliton::primitives::sha3_256::hash(self.pk.as_bytes()).to_vec()
}
/// Hex-encoded fingerprint (64 chars).
#[wasm_bindgen(js_name = "fingerprintHex")]
pub fn fingerprint_hex(&self) -> String {
soliton::primitives::sha3_256::fingerprint_hex(self.pk.as_bytes())
}
/// Hybrid sign (Ed25519 + ML-DSA-65). Returns signature bytes (3373 bytes).
pub fn sign(&self, message: &[u8]) -> Result<Vec<u8>, JsValue> {
let sk = self
.sk
.as_ref()
.ok_or_else(|| crate::errors::js_error("no secret key"))?;
let sig = soliton::identity::hybrid_sign(sk, message).map_err(to_js_err)?;
Ok(sig.as_bytes().to_vec())
}
/// Verify a hybrid signature against this identity's public key.
pub fn verify(&self, message: &[u8], signature: &[u8]) -> Result<(), JsValue> {
let sig = soliton::identity::HybridSignature::from_bytes(signature.to_vec())
.map_err(to_js_err)?;
soliton::identity::hybrid_verify(&self.pk, message, &sig).map_err(to_js_err)
}
/// Zeroize the secret key.
pub fn free(&mut self) {
self.sk = None;
}
}
/// Verify a hybrid signature against raw public key bytes.
#[wasm_bindgen(js_name = "hybridVerify")]
pub fn hybrid_verify(pk: &[u8], message: &[u8], signature: &[u8]) -> Result<(), JsValue> {
let pk = soliton::identity::IdentityPublicKey::from_bytes(pk.to_vec()).map_err(to_js_err)?;
let sig =
soliton::identity::HybridSignature::from_bytes(signature.to_vec()).map_err(to_js_err)?;
soliton::identity::hybrid_verify(&pk, message, &sig).map_err(to_js_err)
}

311
soliton_wasm/src/kex.rs Normal file
View file

@ -0,0 +1,311 @@
//! LO-KEX: asynchronous key exchange.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Sign a pre-key with the identity key. Returns signature bytes (3373 bytes).
#[wasm_bindgen(js_name = "kexSignPrekey")]
pub fn kex_sign_prekey(ik_sk: &[u8], spk_pub: &[u8]) -> Result<Vec<u8>, JsValue> {
let sk = soliton::identity::IdentitySecretKey::from_bytes(ik_sk.to_vec()).map_err(to_js_err)?;
let spk =
soliton::primitives::xwing::PublicKey::from_bytes(spk_pub.to_vec()).map_err(to_js_err)?;
let sig = soliton::kex::sign_prekey(&sk, &spk).map_err(to_js_err)?;
Ok(sig.as_bytes().to_vec())
}
/// Verify a pre-key bundle. Throws on failure.
#[wasm_bindgen(js_name = "kexVerifyBundle")]
#[allow(clippy::too_many_arguments)]
pub fn kex_verify_bundle(
bundle_ik_pk: &[u8],
known_ik_pk: &[u8],
spk_pub: &[u8],
spk_id: u32,
spk_sig: &[u8],
crypto_version: &str,
opk_pub: Option<Vec<u8>>,
opk_id: Option<u32>,
) -> Result<(), JsValue> {
let bik = soliton::identity::IdentityPublicKey::from_bytes(bundle_ik_pk.to_vec())
.map_err(to_js_err)?;
let kik = soliton::identity::IdentityPublicKey::from_bytes(known_ik_pk.to_vec())
.map_err(to_js_err)?;
let spk =
soliton::primitives::xwing::PublicKey::from_bytes(spk_pub.to_vec()).map_err(to_js_err)?;
let sig =
soliton::identity::HybridSignature::from_bytes(spk_sig.to_vec()).map_err(to_js_err)?;
let opk = match opk_pub {
Some(data) => {
Some(soliton::primitives::xwing::PublicKey::from_bytes(data).map_err(to_js_err)?)
}
None => None,
};
let bundle = soliton::kex::PreKeyBundle {
ik_pub: bik,
crypto_version: crypto_version.to_string(),
spk_pub: spk,
spk_id,
spk_sig: sig,
opk_pub: opk,
opk_id,
};
soliton::kex::verify_bundle(bundle, &kik).map_err(to_js_err)?;
Ok(())
}
/// Result of session initiation (Alice's side).
#[wasm_bindgen]
pub struct InitiatedSession {
inner: Option<soliton::kex::InitiatedSession>,
}
#[wasm_bindgen]
impl InitiatedSession {
/// Encoded session init message bytes.
#[wasm_bindgen(js_name = "sessionInitEncoded")]
pub fn session_init_encoded(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
soliton::kex::encode_session_init(&s.session_init).map_err(to_js_err)
}
/// Extract root key (32 bytes). Destructive — zeroed after first call.
#[wasm_bindgen(js_name = "takeRootKey")]
pub fn take_root_key(&mut self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.take_root_key().to_vec())
}
/// Extract initial chain key (32 bytes). Destructive.
#[wasm_bindgen(js_name = "takeInitialChainKey")]
pub fn take_initial_chain_key(&mut self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.take_initial_chain_key().to_vec())
}
/// Alice's ephemeral public key (1216 bytes).
#[wasm_bindgen(js_name = "ekPk")]
pub fn ek_pk(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.ek_pk.as_bytes().to_vec())
}
/// Alice's ephemeral secret key (2432 bytes).
#[wasm_bindgen(js_name = "ekSk")]
pub fn ek_sk(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.ek_sk().as_bytes().to_vec())
}
/// Sender hybrid signature (3373 bytes).
#[wasm_bindgen(js_name = "senderSig")]
pub fn sender_sig(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.sender_sig.as_bytes().to_vec())
}
/// Whether an OPK was used.
#[wasm_bindgen(js_name = "opkUsed")]
pub fn opk_used(&self) -> Result<bool, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.opk_used)
}
/// Sender fingerprint (32 bytes).
#[wasm_bindgen(js_name = "senderFingerprint")]
pub fn sender_fingerprint(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.session_init.sender_ik_fingerprint.to_vec())
}
/// Recipient fingerprint (32 bytes).
#[wasm_bindgen(js_name = "recipientFingerprint")]
pub fn recipient_fingerprint(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.session_init.recipient_ik_fingerprint.to_vec())
}
pub fn free(&mut self) {
self.inner = None;
}
}
/// Initiate a session (Alice's side).
#[wasm_bindgen(js_name = "kexInitiate")]
#[allow(clippy::too_many_arguments)]
pub fn kex_initiate(
alice_ik_pk: &[u8],
alice_ik_sk: &[u8],
bundle_ik_pk: &[u8],
spk_pub: &[u8],
spk_id: u32,
spk_sig: &[u8],
crypto_version: &str,
opk_pub: Option<Vec<u8>>,
opk_id: Option<u32>,
) -> Result<InitiatedSession, JsValue> {
let a_pk = soliton::identity::IdentityPublicKey::from_bytes(alice_ik_pk.to_vec())
.map_err(to_js_err)?;
let a_sk = soliton::identity::IdentitySecretKey::from_bytes(alice_ik_sk.to_vec())
.map_err(to_js_err)?;
let b_pk = soliton::identity::IdentityPublicKey::from_bytes(bundle_ik_pk.to_vec())
.map_err(to_js_err)?;
let known_ik = soliton::identity::IdentityPublicKey::from_bytes(bundle_ik_pk.to_vec())
.map_err(to_js_err)?;
let spk =
soliton::primitives::xwing::PublicKey::from_bytes(spk_pub.to_vec()).map_err(to_js_err)?;
let sig =
soliton::identity::HybridSignature::from_bytes(spk_sig.to_vec()).map_err(to_js_err)?;
let opk = match opk_pub {
Some(data) => {
Some(soliton::primitives::xwing::PublicKey::from_bytes(data).map_err(to_js_err)?)
}
None => None,
};
let bundle = soliton::kex::PreKeyBundle {
ik_pub: b_pk,
crypto_version: crypto_version.to_string(),
spk_pub: spk,
spk_id,
spk_sig: sig,
opk_pub: opk,
opk_id,
};
let verified = soliton::kex::verify_bundle(bundle, &known_ik).map_err(to_js_err)?;
let session = soliton::kex::initiate_session(&a_pk, &a_sk, &verified).map_err(to_js_err)?;
Ok(InitiatedSession {
inner: Some(session),
})
}
/// Result of session receipt (Bob's side).
#[wasm_bindgen]
pub struct ReceivedSession {
inner: Option<soliton::kex::ReceivedSession>,
}
#[wasm_bindgen]
impl ReceivedSession {
/// Extract root key (32 bytes). Destructive.
#[wasm_bindgen(js_name = "takeRootKey")]
pub fn take_root_key(&mut self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.take_root_key().to_vec())
}
/// Extract initial chain key (32 bytes). Destructive.
#[wasm_bindgen(js_name = "takeInitialChainKey")]
pub fn take_initial_chain_key(&mut self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.take_initial_chain_key().to_vec())
}
/// Peer's ephemeral public key (1216 bytes).
#[wasm_bindgen(js_name = "peerEk")]
pub fn peer_ek(&self) -> Result<Vec<u8>, JsValue> {
let s = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("session consumed"))?;
Ok(s.peer_ek.as_bytes().to_vec())
}
pub fn free(&mut self) {
self.inner = None;
}
}
/// Receive a session (Bob's side).
#[wasm_bindgen(js_name = "kexReceive")]
pub fn kex_receive(
bob_ik_pk: &[u8],
bob_ik_sk: &[u8],
alice_ik_pk: &[u8],
session_init_encoded: &[u8],
sender_sig: &[u8],
spk_sk: &[u8],
opk_sk: Option<Vec<u8>>,
) -> Result<ReceivedSession, JsValue> {
let b_pk =
soliton::identity::IdentityPublicKey::from_bytes(bob_ik_pk.to_vec()).map_err(to_js_err)?;
let b_sk =
soliton::identity::IdentitySecretKey::from_bytes(bob_ik_sk.to_vec()).map_err(to_js_err)?;
let a_pk = soliton::identity::IdentityPublicKey::from_bytes(alice_ik_pk.to_vec())
.map_err(to_js_err)?;
let si = soliton::kex::decode_session_init(session_init_encoded).map_err(to_js_err)?;
let sig =
soliton::identity::HybridSignature::from_bytes(sender_sig.to_vec()).map_err(to_js_err)?;
let spk_secret =
soliton::primitives::xwing::SecretKey::from_bytes(spk_sk.to_vec()).map_err(to_js_err)?;
let opk_secret = match opk_sk {
Some(data) => {
Some(soliton::primitives::xwing::SecretKey::from_bytes(data).map_err(to_js_err)?)
}
None => None,
};
let session = soliton::kex::receive_session(
&b_pk,
&b_sk,
&a_pk,
&si,
&sig,
&spk_secret,
opk_secret.as_ref(),
)
.map_err(to_js_err)?;
Ok(ReceivedSession {
inner: Some(session),
})
}
/// Build first-message AAD from fingerprints and encoded session init.
#[wasm_bindgen(js_name = "kexBuildFirstMessageAad")]
pub fn kex_build_first_message_aad(
sender_fp: &[u8],
recipient_fp: &[u8],
session_init_encoded: &[u8],
) -> Result<Vec<u8>, JsValue> {
if sender_fp.len() != 32 || recipient_fp.len() != 32 {
return Err(crate::errors::js_error("fingerprints must be 32 bytes"));
}
let sfp: &[u8; 32] = sender_fp.try_into().unwrap();
let rfp: &[u8; 32] = recipient_fp.try_into().unwrap();
soliton::kex::build_first_message_aad_from_encoded(sfp, rfp, session_init_encoded)
.map_err(to_js_err)
}

24
soliton_wasm/src/lib.rs Normal file
View file

@ -0,0 +1,24 @@
//! WebAssembly bindings for libsoliton.
//!
//! Provides the full libsoliton API to JavaScript/TypeScript via wasm-bindgen.
//! All byte arrays are exchanged as `Uint8Array` (via `Vec<u8>` / `&[u8]`).
//! Errors are thrown as JavaScript `Error` objects with descriptive messages.
use wasm_bindgen::prelude::*;
mod auth;
mod call;
mod errors;
mod identity;
mod kex;
mod primitives;
mod ratchet;
mod storage;
mod stream;
mod verification;
/// Library version string.
#[wasm_bindgen(js_name = "version")]
pub fn version() -> String {
soliton::VERSION.to_string()
}

View file

@ -0,0 +1,84 @@
//! Primitive cryptographic operations.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// SHA3-256 hash. Returns 32 bytes.
#[wasm_bindgen(js_name = "sha3_256")]
pub fn sha3_256(data: &[u8]) -> Vec<u8> {
soliton::primitives::sha3_256::hash(data).to_vec()
}
/// SHA3-256 hex fingerprint of arbitrary data. Returns 64-char hex string.
#[wasm_bindgen(js_name = "fingerprintHex")]
pub fn fingerprint_hex(data: &[u8]) -> String {
soliton::primitives::sha3_256::fingerprint_hex(data)
}
/// HMAC-SHA3-256. Returns 32 bytes.
#[wasm_bindgen(js_name = "hmacSha3_256")]
pub fn hmac_sha3_256(key: &[u8], data: &[u8]) -> Vec<u8> {
soliton::primitives::hmac::hmac_sha3_256(key, data).to_vec()
}
/// Constant-time HMAC-SHA3-256 verification.
#[wasm_bindgen(js_name = "hmacSha3_256Verify")]
pub fn hmac_sha3_256_verify(a: &[u8], b: &[u8]) -> Result<bool, JsValue> {
if a.len() != 32 || b.len() != 32 {
return Err(crate::errors::js_error("both inputs must be 32 bytes"));
}
let a: &[u8; 32] = a.try_into().unwrap();
let b: &[u8; 32] = b.try_into().unwrap();
Ok(soliton::primitives::hmac::hmac_sha3_256_verify_raw(a, b))
}
/// HKDF-SHA3-256 extract-and-expand. Returns `length` bytes.
#[wasm_bindgen(js_name = "hkdfSha3_256")]
pub fn hkdf_sha3_256(
salt: &[u8],
ikm: &[u8],
info: &[u8],
length: usize,
) -> Result<Vec<u8>, JsValue> {
let mut out = vec![0u8; length];
soliton::primitives::hkdf::hkdf_sha3_256(salt, ikm, info, &mut out).map_err(to_js_err)?;
Ok(out)
}
/// Generate an X-Wing keypair. Returns { publicKey: Uint8Array, secretKey: Uint8Array }.
#[wasm_bindgen(js_name = "xwingKeygen")]
pub fn xwing_keygen() -> Result<JsValue, JsValue> {
let (pk, sk) = soliton::primitives::xwing::keygen().map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"publicKey".into(),
&js_sys::Uint8Array::from(pk.as_bytes()),
)?;
js_sys::Reflect::set(
&obj,
&"secretKey".into(),
&js_sys::Uint8Array::from(sk.as_bytes()),
)?;
Ok(obj.into())
}
/// Argon2id key derivation. Returns `outLength` bytes.
#[wasm_bindgen(js_name = "argon2id")]
pub fn argon2id(
password: &[u8],
salt: &[u8],
m_cost: u32,
t_cost: u32,
p_cost: u32,
out_length: usize,
) -> Result<Vec<u8>, JsValue> {
let params = soliton::primitives::argon2::Argon2Params {
m_cost,
t_cost,
p_cost,
};
let mut out = vec![0u8; out_length];
soliton::primitives::argon2::argon2id(password, salt, params, &mut out).map_err(to_js_err)?;
Ok(out)
}

308
soliton_wasm/src/ratchet.rs Normal file
View file

@ -0,0 +1,308 @@
//! Double ratchet session.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
use zeroize::Zeroize;
/// Double ratchet session state.
///
/// Call `free()` when done to zeroize all key material.
#[wasm_bindgen]
pub struct Ratchet {
inner: Option<soliton::ratchet::RatchetState>,
}
#[wasm_bindgen]
impl Ratchet {
/// Initialize Alice's side (initiator).
#[wasm_bindgen(js_name = "initAlice")]
pub fn init_alice(
root_key: &[u8],
chain_key: &[u8],
local_fp: &[u8],
remote_fp: &[u8],
peer_ek: &[u8],
ek_sk: &[u8],
) -> Result<Ratchet, JsValue> {
let mut rk = to_32("root_key", root_key)?;
let mut ck = to_32("chain_key", chain_key)?;
let lfp = to_32("local_fp", local_fp)?;
let rfp = to_32("remote_fp", remote_fp)?;
let ek_pub = soliton::primitives::xwing::PublicKey::from_bytes(peer_ek.to_vec())
.map_err(to_js_err)?;
let ek_secret =
soliton::primitives::xwing::SecretKey::from_bytes(ek_sk.to_vec()).map_err(to_js_err)?;
let state = soliton::ratchet::RatchetState::init_alice(rk, ck, lfp, rfp, ek_pub, ek_secret)
.map_err(to_js_err)?;
rk.zeroize();
ck.zeroize();
Ok(Self { inner: Some(state) })
}
/// Initialize Bob's side (responder).
#[wasm_bindgen(js_name = "initBob")]
pub fn init_bob(
root_key: &[u8],
chain_key: &[u8],
local_fp: &[u8],
remote_fp: &[u8],
peer_ek: &[u8],
) -> Result<Ratchet, JsValue> {
let mut rk = to_32("root_key", root_key)?;
let mut ck = to_32("chain_key", chain_key)?;
let lfp = to_32("local_fp", local_fp)?;
let rfp = to_32("remote_fp", remote_fp)?;
let ek_pub = soliton::primitives::xwing::PublicKey::from_bytes(peer_ek.to_vec())
.map_err(to_js_err)?;
let state = soliton::ratchet::RatchetState::init_bob(rk, ck, lfp, rfp, ek_pub)
.map_err(to_js_err)?;
rk.zeroize();
ck.zeroize();
Ok(Self { inner: Some(state) })
}
/// Encrypt a plaintext message. Returns { header: Uint8Array, ciphertext: Uint8Array }.
pub fn encrypt(&mut self, plaintext: &[u8]) -> Result<JsValue, JsValue> {
let state = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
let msg = state.encrypt(plaintext).map_err(to_js_err)?;
let header_bytes = encode_header(&msg.header);
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"header".into(),
&js_sys::Uint8Array::from(header_bytes.as_slice()),
)?;
js_sys::Reflect::set(
&obj,
&"ciphertext".into(),
&js_sys::Uint8Array::from(msg.ciphertext.as_slice()),
)?;
Ok(obj.into())
}
/// Decrypt a received message.
pub fn decrypt(&mut self, header: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>, JsValue> {
let state = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
let rh = decode_header(header)?;
let pt = state.decrypt(&rh, ciphertext).map_err(to_js_err)?;
Ok(pt.to_vec())
}
/// Encrypt the first message (pre-ratchet).
/// Returns { encryptedPayload: Uint8Array, ratchetInitKey: Uint8Array }.
#[wasm_bindgen(js_name = "encryptFirstMessage")]
pub fn encrypt_first_message(
chain_key: &[u8],
plaintext: &[u8],
aad: &[u8],
) -> Result<JsValue, JsValue> {
let ck = zeroizing_32("chain_key", chain_key)?;
let (ct, rik) = soliton::ratchet::RatchetState::encrypt_first_message(ck, plaintext, aad)
.map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"encryptedPayload".into(),
&js_sys::Uint8Array::from(ct.as_slice()),
)?;
js_sys::Reflect::set(
&obj,
&"ratchetInitKey".into(),
&js_sys::Uint8Array::from(&*rik as &[u8]),
)?;
Ok(obj.into())
}
/// Decrypt the first message (pre-ratchet).
/// Returns { plaintext: Uint8Array, ratchetInitKey: Uint8Array }.
#[wasm_bindgen(js_name = "decryptFirstMessage")]
pub fn decrypt_first_message(
chain_key: &[u8],
encrypted_payload: &[u8],
aad: &[u8],
) -> Result<JsValue, JsValue> {
let ck = zeroizing_32("chain_key", chain_key)?;
let (pt, rik) =
soliton::ratchet::RatchetState::decrypt_first_message(ck, encrypted_payload, aad)
.map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"plaintext".into(),
&js_sys::Uint8Array::from(&*pt as &[u8]),
)?;
js_sys::Reflect::set(
&obj,
&"ratchetInitKey".into(),
&js_sys::Uint8Array::from(&*rik as &[u8]),
)?;
Ok(obj.into())
}
/// Serialize the ratchet state. Consumes the ratchet.
/// Returns { blob: Uint8Array, epoch: BigInt }.
#[wasm_bindgen(js_name = "toBytes")]
#[allow(clippy::wrong_self_convention)]
pub fn to_bytes(&mut self) -> Result<JsValue, JsValue> {
let state = self
.inner
.take()
.ok_or_else(|| crate::errors::js_error("ratchet already consumed"))?;
let (blob, epoch) = state.to_bytes().map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"blob".into(),
&js_sys::Uint8Array::from(&*blob as &[u8]),
)?;
js_sys::Reflect::set(&obj, &"epoch".into(), &JsValue::from(epoch))?;
Ok(obj.into())
}
/// Deserialize ratchet state with anti-rollback protection.
#[wasm_bindgen(js_name = "fromBytes")]
pub fn from_bytes(data: &[u8], min_epoch: u64) -> Result<Ratchet, JsValue> {
let state = soliton::ratchet::RatchetState::from_bytes_with_min_epoch(data, min_epoch)
.map_err(to_js_err)?;
Ok(Self { inner: Some(state) })
}
/// Whether the ratchet can be serialized.
#[wasm_bindgen(js_name = "canSerialize")]
pub fn can_serialize(&self) -> Result<bool, JsValue> {
let state = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
Ok(state.can_serialize())
}
/// Current epoch number.
pub fn epoch(&self) -> Result<u64, JsValue> {
let state = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
Ok(state.epoch())
}
/// Reset the ratchet (zeroize all keys).
pub fn reset(&mut self) -> Result<(), JsValue> {
let state = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
state.reset();
Ok(())
}
/// Derive call keys for encrypted voice/video.
#[wasm_bindgen(js_name = "deriveCallKeys")]
pub fn derive_call_keys(
&self,
kem_ss: &[u8],
call_id: &[u8],
) -> Result<super::call::CallKeys, JsValue> {
let state = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("ratchet consumed or closed"))?;
if kem_ss.len() != 32 {
return Err(crate::errors::js_error("kem_ss must be 32 bytes"));
}
if call_id.len() != 16 {
return Err(crate::errors::js_error("call_id must be 16 bytes"));
}
let ss: &[u8; 32] = kem_ss.try_into().unwrap();
let cid: &[u8; 16] = call_id.try_into().unwrap();
let keys = state.derive_call_keys(ss, cid).map_err(to_js_err)?;
Ok(super::call::CallKeys::from_inner(keys))
}
pub fn free(&mut self) {
if let Some(mut state) = self.inner.take() {
state.reset();
}
}
}
// Header serialization — same wire format as the Python binding.
fn encode_header(h: &soliton::ratchet::RatchetHeader) -> Vec<u8> {
let pk_bytes = h.ratchet_pk.as_bytes();
let has_ct = h.kem_ct.is_some();
let size = 1216 + 1 + if has_ct { 2 + 1120 } else { 0 } + 4 + 4;
let mut buf = Vec::with_capacity(size);
buf.extend_from_slice(pk_bytes);
if let Some(ref ct) = h.kem_ct {
buf.push(0x01);
let ct_bytes = ct.as_bytes();
buf.extend_from_slice(&(ct_bytes.len() as u16).to_be_bytes());
buf.extend_from_slice(ct_bytes);
} else {
buf.push(0x00);
}
buf.extend_from_slice(&h.n.to_be_bytes());
buf.extend_from_slice(&h.pn.to_be_bytes());
buf
}
fn decode_header(data: &[u8]) -> Result<soliton::ratchet::RatchetHeader, JsValue> {
if data.len() < 1216 + 1 + 4 + 4 {
return Err(crate::errors::js_error("header too short"));
}
let ratchet_pk = soliton::primitives::xwing::PublicKey::from_bytes(data[..1216].to_vec())
.map_err(to_js_err)?;
let has_ct = data[1216];
if has_ct != 0x00 && has_ct != 0x01 {
return Err(crate::errors::js_error(
"invalid has_kem_ct flag (expected 0x00 or 0x01)",
));
}
let rest = if has_ct == 0x01 {
if data.len() < 1216 + 1 + 2 + 1120 + 4 + 4 {
return Err(crate::errors::js_error("header too short for kem_ct"));
}
&data[1216 + 1 + 2 + 1120..]
} else {
&data[1216 + 1..]
};
let kem_ct = if has_ct == 0x01 {
Some(
soliton::primitives::xwing::Ciphertext::from_bytes(
data[1216 + 1 + 2..1216 + 1 + 2 + 1120].to_vec(),
)
.map_err(to_js_err)?,
)
} else {
None
};
if rest.len() < 8 {
return Err(crate::errors::js_error("header missing counters"));
}
let n = u32::from_be_bytes(rest[..4].try_into().unwrap());
let pn = u32::from_be_bytes(rest[4..8].try_into().unwrap());
Ok(soliton::ratchet::RatchetHeader {
ratchet_pk,
kem_ct,
n,
pn,
})
}
fn to_32(name: &str, data: &[u8]) -> Result<[u8; 32], JsValue> {
data.try_into()
.map_err(|_| crate::errors::js_error(&format!("{name} must be 32 bytes")))
}
fn zeroizing_32(name: &str, data: &[u8]) -> Result<zeroize::Zeroizing<[u8; 32]>, JsValue> {
let arr = to_32(name, data)?;
Ok(zeroize::Zeroizing::new(arr))
}

152
soliton_wasm/src/storage.rs Normal file
View file

@ -0,0 +1,152 @@
//! Encrypted storage.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Encrypted storage key ring.
///
/// Call `free()` when done to zeroize key material.
#[wasm_bindgen]
pub struct StorageKeyRing {
inner: Option<soliton::storage::StorageKeyRing>,
}
#[wasm_bindgen]
impl StorageKeyRing {
/// Create a keyring with an initial active key.
#[wasm_bindgen(constructor)]
pub fn new(version: u8, key: &[u8]) -> Result<StorageKeyRing, JsValue> {
if key.len() != 32 {
return Err(crate::errors::js_error("key must be 32 bytes"));
}
let key_arr: [u8; 32] = key.try_into().unwrap();
let storage_key = soliton::storage::StorageKey::new(version, key_arr).map_err(to_js_err)?;
let ring = soliton::storage::StorageKeyRing::new(storage_key).map_err(to_js_err)?;
Ok(Self { inner: Some(ring) })
}
/// Add a key to the ring.
#[wasm_bindgen(js_name = "addKey")]
pub fn add_key(&mut self, version: u8, key: &[u8], make_active: bool) -> Result<(), JsValue> {
if key.len() != 32 {
return Err(crate::errors::js_error("key must be 32 bytes"));
}
let ring = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
let key_arr: [u8; 32] = key.try_into().unwrap();
let storage_key = soliton::storage::StorageKey::new(version, key_arr).map_err(to_js_err)?;
ring.add_key(storage_key, make_active).map_err(to_js_err)?;
Ok(())
}
/// Remove a key version from the ring.
#[wasm_bindgen(js_name = "removeKey")]
pub fn remove_key(&mut self, version: u8) -> Result<(), JsValue> {
let ring = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
ring.remove_key(version).map_err(to_js_err)?;
Ok(())
}
/// Encrypt a community storage blob.
#[wasm_bindgen(js_name = "encryptBlob")]
pub fn encrypt_blob(
&self,
channel_id: &str,
segment_id: &str,
plaintext: &[u8],
compress: Option<bool>,
) -> Result<Vec<u8>, JsValue> {
let ring = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
let key = ring
.active_key()
.ok_or_else(|| crate::errors::js_error("no active key"))?;
soliton::storage::encrypt_blob(
key,
plaintext,
channel_id,
segment_id,
compress.unwrap_or(false),
)
.map_err(to_js_err)
}
/// Decrypt a community storage blob.
#[wasm_bindgen(js_name = "decryptBlob")]
pub fn decrypt_blob(
&self,
channel_id: &str,
segment_id: &str,
blob: &[u8],
) -> Result<Vec<u8>, JsValue> {
let ring = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
let pt = soliton::storage::decrypt_blob(ring, blob, channel_id, segment_id)
.map_err(to_js_err)?;
Ok(pt.to_vec())
}
/// Encrypt a DM queue blob.
#[wasm_bindgen(js_name = "encryptDmQueue")]
pub fn encrypt_dm_queue(
&self,
recipient_fp: &[u8],
batch_id: &str,
plaintext: &[u8],
compress: Option<bool>,
) -> Result<Vec<u8>, JsValue> {
if recipient_fp.len() != 32 {
return Err(crate::errors::js_error("recipient_fp must be 32 bytes"));
}
let ring = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
let key = ring
.active_key()
.ok_or_else(|| crate::errors::js_error("no active key"))?;
let fp: &[u8; 32] = recipient_fp.try_into().unwrap();
soliton::storage::encrypt_dm_queue_blob(
key,
plaintext,
fp,
batch_id,
compress.unwrap_or(false),
)
.map_err(to_js_err)
}
/// Decrypt a DM queue blob.
#[wasm_bindgen(js_name = "decryptDmQueue")]
pub fn decrypt_dm_queue(
&self,
recipient_fp: &[u8],
batch_id: &str,
blob: &[u8],
) -> Result<Vec<u8>, JsValue> {
if recipient_fp.len() != 32 {
return Err(crate::errors::js_error("recipient_fp must be 32 bytes"));
}
let ring = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("keyring closed"))?;
let fp: &[u8; 32] = recipient_fp.try_into().unwrap();
let pt =
soliton::storage::decrypt_dm_queue_blob(ring, blob, fp, batch_id).map_err(to_js_err)?;
Ok(pt.to_vec())
}
pub fn free(&mut self) {
self.inner = None;
}
}

182
soliton_wasm/src/stream.rs Normal file
View file

@ -0,0 +1,182 @@
//! Streaming AEAD.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Streaming encryptor.
///
/// Call `free()` when done.
#[wasm_bindgen]
pub struct StreamEncryptor {
inner: Option<soliton::streaming::StreamEncryptor>,
}
#[wasm_bindgen]
impl StreamEncryptor {
/// Create a streaming encryptor.
#[wasm_bindgen(constructor)]
pub fn new(
key: &[u8],
aad: Option<Vec<u8>>,
compress: Option<bool>,
) -> Result<StreamEncryptor, JsValue> {
if key.len() != 32 {
return Err(crate::errors::js_error("key must be 32 bytes"));
}
let key_arr: &[u8; 32] = key.try_into().unwrap();
let aad_bytes = aad.as_deref().unwrap_or(&[]);
let enc =
soliton::streaming::stream_encrypt_init(key_arr, aad_bytes, compress.unwrap_or(false))
.map_err(to_js_err)?;
Ok(Self { inner: Some(enc) })
}
/// The 26-byte stream header. Send this before any chunks.
pub fn header(&self) -> Result<Vec<u8>, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("encryptor closed"))?;
Ok(inner.header().to_vec())
}
/// Encrypt one chunk. Non-final chunks must be exactly 1,048,576 bytes.
#[wasm_bindgen(js_name = "encryptChunk")]
pub fn encrypt_chunk(
&mut self,
plaintext: &[u8],
is_last: Option<bool>,
) -> Result<Vec<u8>, JsValue> {
let inner = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("encryptor closed"))?;
inner
.encrypt_chunk(plaintext, is_last.unwrap_or(false))
.map_err(to_js_err)
}
/// Encrypt a specific chunk by index (random access, stateless).
#[wasm_bindgen(js_name = "encryptChunkAt")]
pub fn encrypt_chunk_at(
&self,
index: u64,
plaintext: &[u8],
is_last: Option<bool>,
) -> Result<Vec<u8>, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("encryptor closed"))?;
inner
.encrypt_chunk_at(index, is_last.unwrap_or(false), plaintext)
.map_err(to_js_err)
}
/// Whether the encryptor has been finalized.
#[wasm_bindgen(js_name = "isFinalized")]
pub fn is_finalized(&self) -> Result<bool, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("encryptor closed"))?;
Ok(inner.is_finalized())
}
pub fn free(&mut self) {
self.inner = None;
}
}
/// Streaming decryptor.
///
/// Call `free()` when done.
#[wasm_bindgen]
pub struct StreamDecryptor {
inner: Option<soliton::streaming::StreamDecryptor>,
}
#[wasm_bindgen]
impl StreamDecryptor {
/// Create a streaming decryptor.
#[wasm_bindgen(constructor)]
pub fn new(
key: &[u8],
header: &[u8],
aad: Option<Vec<u8>>,
) -> Result<StreamDecryptor, JsValue> {
if key.len() != 32 {
return Err(crate::errors::js_error("key must be 32 bytes"));
}
if header.len() != 26 {
return Err(crate::errors::js_error("header must be 26 bytes"));
}
let key_arr: &[u8; 32] = key.try_into().unwrap();
let hdr_arr: &[u8; 26] = header.try_into().unwrap();
let aad_bytes = aad.as_deref().unwrap_or(&[]);
let dec = soliton::streaming::stream_decrypt_init(key_arr, hdr_arr, aad_bytes)
.map_err(to_js_err)?;
Ok(Self { inner: Some(dec) })
}
/// Decrypt the next sequential chunk. Returns { plaintext: Uint8Array, isLast: boolean }.
#[wasm_bindgen(js_name = "decryptChunk")]
pub fn decrypt_chunk(&mut self, chunk: &[u8]) -> Result<JsValue, JsValue> {
let inner = self
.inner
.as_mut()
.ok_or_else(|| crate::errors::js_error("decryptor closed"))?;
let (pt, is_last) = inner.decrypt_chunk(chunk).map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"plaintext".into(),
&js_sys::Uint8Array::from(&*pt as &[u8]),
)?;
js_sys::Reflect::set(&obj, &"isLast".into(), &JsValue::from_bool(is_last))?;
Ok(obj.into())
}
/// Decrypt a specific chunk by index (random access).
/// Returns { plaintext: Uint8Array, isLast: boolean }.
#[wasm_bindgen(js_name = "decryptChunkAt")]
pub fn decrypt_chunk_at(&self, index: u64, chunk: &[u8]) -> Result<JsValue, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("decryptor closed"))?;
let (pt, is_last) = inner.decrypt_chunk_at(index, chunk).map_err(to_js_err)?;
let obj = js_sys::Object::new();
js_sys::Reflect::set(
&obj,
&"plaintext".into(),
&js_sys::Uint8Array::from(&*pt as &[u8]),
)?;
js_sys::Reflect::set(&obj, &"isLast".into(), &JsValue::from_bool(is_last))?;
Ok(obj.into())
}
/// Whether the decryptor has seen the final chunk.
#[wasm_bindgen(js_name = "isFinalized")]
pub fn is_finalized(&self) -> Result<bool, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("decryptor closed"))?;
Ok(inner.is_finalized())
}
/// Next expected sequential chunk index.
#[wasm_bindgen(js_name = "expectedIndex")]
pub fn expected_index(&self) -> Result<u64, JsValue> {
let inner = self
.inner
.as_ref()
.ok_or_else(|| crate::errors::js_error("decryptor closed"))?;
Ok(inner.expected_index())
}
pub fn free(&mut self) {
self.inner = None;
}
}

View file

@ -0,0 +1,12 @@
//! Verification phrases for out-of-band identity verification.
use crate::errors::to_js_err;
use wasm_bindgen::prelude::*;
/// Generate a verification phrase from two identity public keys.
/// Each key must be 3200 bytes. Returns a human-readable phrase (6 EFF words).
/// The phrase is symmetric — swapping the keys produces the same phrase.
#[wasm_bindgen(js_name = "verificationPhrase")]
pub fn verification_phrase(pk_a: &[u8], pk_b: &[u8]) -> Result<String, JsValue> {
soliton::verification::verification_phrase(pk_a, pk_b).map_err(to_js_err)
}