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
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:
commit
1d99048c95
165830 changed files with 79062 additions and 0 deletions
46
soliton_wasm/src/auth.rs
Normal file
46
soliton_wasm/src/auth.rs
Normal 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
54
soliton_wasm/src/call.rs
Normal 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) }
|
||||
}
|
||||
}
|
||||
16
soliton_wasm/src/errors.rs
Normal file
16
soliton_wasm/src/errors.rs
Normal 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()
|
||||
}
|
||||
102
soliton_wasm/src/identity.rs
Normal file
102
soliton_wasm/src/identity.rs
Normal 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
311
soliton_wasm/src/kex.rs
Normal 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
24
soliton_wasm/src/lib.rs
Normal 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()
|
||||
}
|
||||
84
soliton_wasm/src/primitives.rs
Normal file
84
soliton_wasm/src/primitives.rs
Normal 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
308
soliton_wasm/src/ratchet.rs
Normal 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
152
soliton_wasm/src/storage.rs
Normal 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
182
soliton_wasm/src/stream.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
12
soliton_wasm/src/verification.rs
Normal file
12
soliton_wasm/src/verification.rs
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue