Compare commits

...

2 Commits

Author SHA1 Message Date
a825c77424 feat(web): E2E sealed text through the blind relay — Phase 4 milestone
The Milestone-1 payoff: a sealed 1:1 text message travels end-to-end through the
real Phase-1 relay, encrypted with the full client crypto path, verified in
Firefox.

- lib/keystore.ts — IndexedDbKeyStore behind a KeyStore interface: a passphrase
  is stretched with Argon2id (hash-wasm) into an AES-GCM key, and every secret
  state blob (identity seed, prekey secrets, ratchet session) is sealed at rest.
  TauriKeyStore later swaps in behind the same interface.
- lib/relay.ts — thin client for the blind relay's /v1 (push/pull/ack/health),
  payloads base64, queue ids hex.
- lib/demo.ts — one page plays Alice and Bob: Alice publishes a signed invite;
  Bob verifies it, runs PQXDH (X25519 + ML-KEM-768) + the Double Ratchet, and a
  sealed message goes each way through the relay, persisting state every hop.
- App.tsx renders the decrypted text at each endpoint and the relay's stored
  rows — opaque queue id + envelope id + ciphertext, no identity/sender —
  demonstrating the relay is blind.
- vite.config proxies /v1 to buh-api (same-origin, no CORS); index.ts re-exports
  the session facade.

Verified live: Alice read "hello alice — bob here", Bob read "hi bob — got it,
sealed end-to-end", across two uncorrelated random queues, zero console errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
2026-06-30 13:43:32 +03:00
e33ce20495 feat(crypto): session-state serialization + WASM session facade (Phase 4)
The boundary is stateless, so the JS KeyStore must own persistence — that needs
every piece of session state serialisable.

State serialization (versioned blobs, bounds-checked parsing, secrets within):
- MlKemSecretKey to/from its 64-byte (d‖z) keygen seed.
- PrekeySecrets (spk + ml-kem dk + optional opk).
- RatchetState: keys, chains, counters, and banked skipped keys; the public
  ratchet key is recomputed on load. A mid-conversation serialize→restore test
  (with a banked skipped key) proves the conversation continues seamlessly.

WASM facade (ffi.rs, `wasm` feature) — the envelope-oriented, state-returning
API: generate_identity, publishable_prekey_bundle, create_invite/parse_invite,
initiate_session/accept_session, encrypt_message/decrypt_message. Each takes
opaque state blob(s) and returns new one(s); compound returns via wasm-bindgen
structs with getters. Generated d.ts has clean Uint8Array signatures.

A native integration test (tests/session_flow.rs) drives the exact blob handoff
the web demo will perform — invite → parse → initiate → accept → bidirectional
ratcheted chat — persisting and reloading identity/secrets/session at every hop.
52 native tests + 5 wasm-bindgen tests green; clippy --all-features clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UMbqdSzDP4F7Y9AdK5LXaV
2026-06-30 13:36:01 +03:00
14 changed files with 1090 additions and 76 deletions

View File

@@ -1,15 +1,37 @@
//! The `wasm-bindgen` FFI surface, compiled only under the `wasm` feature for the web client.
//!
//! Phase 0 keeps this deliberately small: `echo` proves the `Uint8Array` boundary marshals
//! correctly, and the `*_self_test` helpers run the native KATs *inside the browser* so the
//! web app can confirm the entire crypto stack — the TLV codec, XChaCha20-Poly1305, and
//! ML-DSA-65 keygen/sign/verify over the `wasm_js` getrandom backend — behaves identically to
//! native before any real session code depends on it. The full envelope-oriented facade
//! (`generateIdentity`, `createInvite`, …) lands in Phase 4.
//! Two layers live here. The `*_self_test` helpers run the native KATs *inside the browser* so
//! the web app can confirm the whole crypto stack (TLV codec, XChaCha20-Poly1305, ML-DSA-65
//! over the `wasm_js` getrandom backend) behaves identically to native. On top of that is the
//! **session facade**: a small, envelope-oriented, *state-returning* API. Every mutating call
//! takes the relevant opaque state blob(s) and returns new one(s); the Rust side keeps no
//! state across the boundary, so the JS [`KeyStore`](../../web) owns persistence. State blobs
//! carry secret material — the web layer seals them at rest.
use wasm_bindgen::JsError;
use wasm_bindgen::prelude::wasm_bindgen;
use crate::{aead, identity::IdentityKeyPair, wire};
use crate::identity::{IdentityKeyPair, SEED_LEN};
use crate::invite::{CA_FINGERPRINT_LEN, INVITE_NONCE_LEN, Invite, QueueDescriptor};
use crate::pqxdh::{InitialMessage, initiate, respond};
use crate::prekey::{PrekeyBundle, PrekeySecrets};
use crate::ratchet::RatchetState;
use crate::{aead, wire};
/// Length of an opaque queue identifier carried in an invite (bytes).
const QUEUE_ID_LEN: usize = 32;
/// Map a crate error to a JS exception (message only — never secret state).
fn js(err: crate::CryptoError) -> JsError {
JsError::new(&err.to_string())
}
/// Coerce a slice to a fixed-size array, erroring with `what` on the wrong length.
fn fixed<const N: usize>(bytes: &[u8], what: &str) -> Result<[u8; N], JsError> {
bytes
.try_into()
.map_err(|_| JsError::new(&format!("expected {N}-byte {what}")))
}
/// The crate version, so the bundle can assert it loaded the expected build.
#[wasm_bindgen]
@@ -75,3 +97,272 @@ pub fn identity_self_test() -> bool {
let sig = id.sign(msg);
pk.verify(msg, &sig).is_ok() && pk.verify(b"tampered", &sig).is_err()
}
// ===========================================================================================
// Session facade — state-returning. Blobs are opaque to JS; the KeyStore persists them.
// ===========================================================================================
/// Generate a fresh identity. Returns the identity state blob (the 32-byte seed — the private
/// key; the JS layer seals it). The user *is* this key.
#[wasm_bindgen]
#[must_use]
pub fn generate_identity() -> Vec<u8> {
IdentityKeyPair::generate().to_seed().to_vec()
}
/// The ML-DSA public key for an identity state — a stable fingerprint to show the user.
#[wasm_bindgen]
pub fn identity_public_key(identity: &[u8]) -> Result<Vec<u8>, JsError> {
let id = IdentityKeyPair::from_seed(&fixed::<SEED_LEN>(identity, "identity")?);
Ok(id.public_key().to_bytes())
}
/// A freshly generated prekey set: the secret state to persist, and the public bundle to
/// publish (embed in an invite).
#[wasm_bindgen]
pub struct PrekeyMaterial {
secrets: Vec<u8>,
bundle: Vec<u8>,
}
#[wasm_bindgen]
impl PrekeyMaterial {
/// The secret state blob (persist, sealed).
#[wasm_bindgen(getter)]
#[must_use]
pub fn secrets(&self) -> Vec<u8> {
self.secrets.clone()
}
/// The public, signed prekey bundle.
#[wasm_bindgen(getter)]
#[must_use]
pub fn bundle(&self) -> Vec<u8> {
self.bundle.clone()
}
}
/// Generate a publishable prekey bundle (with a one-time prekey) for an identity.
#[wasm_bindgen]
pub fn publishable_prekey_bundle(identity: &[u8]) -> Result<PrekeyMaterial, JsError> {
let id = IdentityKeyPair::from_seed(&fixed::<SEED_LEN>(identity, "identity")?);
let (secrets, bundle) = PrekeyBundle::generate(&id, true);
Ok(PrekeyMaterial {
secrets: secrets.to_bytes(),
bundle: bundle.encode(),
})
}
/// Create a `buh1:` invite: the inviter's queue (with the hosting node's CA fingerprint to
/// pin) plus their published `bundle`, signed by their identity.
#[wasm_bindgen]
#[allow(clippy::too_many_arguments)]
pub fn create_invite(
identity: &[u8],
queue_id: &[u8],
relay_url: String,
ca_fingerprint: &[u8],
bundle: &[u8],
nonce: &[u8],
expiry_ms: f64,
) -> Result<String, JsError> {
let id = IdentityKeyPair::from_seed(&fixed::<SEED_LEN>(identity, "identity")?);
let queue = QueueDescriptor {
queue_id: fixed::<QUEUE_ID_LEN>(queue_id, "queue id")?,
relay_url,
ca_fingerprint: fixed::<CA_FINGERPRINT_LEN>(ca_fingerprint, "ca fingerprint")?,
};
let bundle = PrekeyBundle::decode(bundle).map_err(js)?;
let nonce = fixed::<INVITE_NONCE_LEN>(nonce, "invite nonce")?;
let invite = Invite::create(&id, queue, bundle, nonce, expiry_ms as u64);
Ok(invite.to_uri())
}
/// A verified, parsed invite. All fields are authentic (the invite verified on parse).
#[wasm_bindgen]
pub struct ParsedInvite {
queue_id: Vec<u8>,
relay_url: String,
ca_fingerprint: Vec<u8>,
identity_public_key: Vec<u8>,
bundle: Vec<u8>,
expiry_ms: f64,
}
#[wasm_bindgen]
impl ParsedInvite {
/// The inviter's relay queue id.
#[wasm_bindgen(getter)]
#[must_use]
pub fn queue_id(&self) -> Vec<u8> {
self.queue_id.clone()
}
/// The inviter's relay base URL.
#[wasm_bindgen(getter)]
#[must_use]
pub fn relay_url(&self) -> String {
self.relay_url.clone()
}
/// The hosting node's CA fingerprint to pin.
#[wasm_bindgen(getter)]
#[must_use]
pub fn ca_fingerprint(&self) -> Vec<u8> {
self.ca_fingerprint.clone()
}
/// The inviter's ML-DSA identity public key.
#[wasm_bindgen(getter)]
#[must_use]
pub fn identity_public_key(&self) -> Vec<u8> {
self.identity_public_key.clone()
}
/// The inviter's prekey bundle (feed to [`initiate_session`]).
#[wasm_bindgen(getter)]
#[must_use]
pub fn bundle(&self) -> Vec<u8> {
self.bundle.clone()
}
/// Invite expiry, epoch-milliseconds (the caller checks it against its own clock).
#[wasm_bindgen(getter)]
#[must_use]
pub fn expiry_ms(&self) -> f64 {
self.expiry_ms
}
}
/// Parse and verify a `buh1:` invite. Throws unless the signature covers the queue/keys and
/// the embedded bundle verifies.
#[wasm_bindgen]
pub fn parse_invite(uri: &str) -> Result<ParsedInvite, JsError> {
let invite = Invite::parse(uri).map_err(js)?;
Ok(ParsedInvite {
queue_id: invite.queue.queue_id.to_vec(),
relay_url: invite.queue.relay_url.clone(),
ca_fingerprint: invite.queue.ca_fingerprint.to_vec(),
identity_public_key: invite.bundle.identity.to_bytes(),
bundle: invite.bundle.encode(),
expiry_ms: invite.expiry_ms as f64,
})
}
/// The initiator's output: the session state to persist and the handshake first-flight to send
/// to the responder's queue (ahead of the first ciphertext).
#[wasm_bindgen]
pub struct InitiatedSession {
session: Vec<u8>,
initial_message: Vec<u8>,
}
#[wasm_bindgen]
impl InitiatedSession {
/// The session state blob (persist, sealed).
#[wasm_bindgen(getter)]
#[must_use]
pub fn session(&self) -> Vec<u8> {
self.session.clone()
}
/// The PQXDH first-flight message to deliver before the first ciphertext.
#[wasm_bindgen(getter)]
#[must_use]
pub fn initial_message(&self) -> Vec<u8> {
self.initial_message.clone()
}
}
/// Start a session as the **initiator** against a verified prekey `bundle` (from an invite).
/// Returns the session state and the handshake first-flight to send.
#[wasm_bindgen]
pub fn initiate_session(identity: &[u8], bundle: &[u8]) -> Result<InitiatedSession, JsError> {
let id = IdentityKeyPair::from_seed(&fixed::<SEED_LEN>(identity, "identity")?);
let bundle = PrekeyBundle::decode(bundle).map_err(js)?;
let (message, root) = initiate(&id, &bundle);
let session = RatchetState::initiator(root, bundle.signed_prekey);
Ok(InitiatedSession {
session: session.to_bytes(),
initial_message: message.encode(),
})
}
/// Complete a session as the **responder** from one's own prekey `secrets` + published
/// `bundle` and the initiator's `initial_message`. Returns the session state. Throws if the
/// initiator's signature does not verify.
#[wasm_bindgen]
pub fn accept_session(
secrets: &[u8],
bundle: &[u8],
initial_message: &[u8],
) -> Result<Vec<u8>, JsError> {
let secrets = PrekeySecrets::from_bytes(secrets).map_err(js)?;
let bundle = PrekeyBundle::decode(bundle).map_err(js)?;
let message = InitialMessage::decode(initial_message).map_err(js)?;
let root = respond(&bundle, &secrets, &message).map_err(js)?;
let session = RatchetState::responder(root, secrets.signed_prekey);
Ok(session.to_bytes())
}
/// The result of encrypting: the advanced session state and the wire message to send.
#[wasm_bindgen]
pub struct EncryptedMessage {
session: Vec<u8>,
message: Vec<u8>,
}
#[wasm_bindgen]
impl EncryptedMessage {
/// The advanced session state blob (persist, sealed).
#[wasm_bindgen(getter)]
#[must_use]
pub fn session(&self) -> Vec<u8> {
self.session.clone()
}
/// The ratchet wire message to deliver.
#[wasm_bindgen(getter)]
#[must_use]
pub fn message(&self) -> Vec<u8> {
self.message.clone()
}
}
/// Encrypt `plaintext` in `session`, returning the advanced session and the wire message.
#[wasm_bindgen]
pub fn encrypt_message(session: &[u8], plaintext: &[u8]) -> Result<EncryptedMessage, JsError> {
let mut state = RatchetState::from_bytes(session).map_err(js)?;
let message = state.encrypt(plaintext).map_err(js)?;
Ok(EncryptedMessage {
session: state.to_bytes(),
message,
})
}
/// The result of decrypting: the advanced session state and the recovered plaintext.
#[wasm_bindgen]
pub struct DecryptedMessage {
session: Vec<u8>,
plaintext: Vec<u8>,
}
#[wasm_bindgen]
impl DecryptedMessage {
/// The advanced session state blob (persist, sealed).
#[wasm_bindgen(getter)]
#[must_use]
pub fn session(&self) -> Vec<u8> {
self.session.clone()
}
/// The recovered plaintext.
#[wasm_bindgen(getter)]
#[must_use]
pub fn plaintext(&self) -> Vec<u8> {
self.plaintext.clone()
}
}
/// Decrypt a wire `message` in `session`, returning the advanced session and the plaintext.
#[wasm_bindgen]
pub fn decrypt_message(session: &[u8], message: &[u8]) -> Result<DecryptedMessage, JsError> {
let mut state = RatchetState::from_bytes(session).map_err(js)?;
let plaintext = state.decrypt(message).map_err(js)?;
Ok(DecryptedMessage {
session: state.to_bytes(),
plaintext,
})
}

View File

@@ -6,10 +6,9 @@
//! Two independent primitives live here; combining them into a root key is `pqxdh`'s job.
//! Randomness comes from the system RNG (getrandom; `wasm_js` in the browser).
use ml_kem::Kem;
use ml_kem::kem::{Decapsulate, Encapsulate, KeyExport};
use ml_kem::kem::{Decapsulate, Encapsulate, KeyExport, KeyInit};
use ml_kem::ml_kem_768::{Ciphertext, DecapsulationKey, EncapsulationKey};
use ml_kem::{Key, MlKem768};
use ml_kem::{Kem, Key, MlKem768, Seed};
use x25519_dalek::{PublicKey as XPublic, StaticSecret as XSecret};
/// Copy a 32-byte shared-secret array out of an `ml-kem` `Array`.
@@ -29,6 +28,9 @@ pub const SHARED_SECRET_LEN: usize = 32;
pub const MLKEM_ENCAPS_KEY_LEN: usize = 1184;
/// Length of an ML-KEM-768 ciphertext (bytes).
pub const MLKEM_CIPHERTEXT_LEN: usize = 1088;
/// Length of the serialised ML-KEM-768 secret key — the 64-byte (d‖z) keygen seed, from which
/// the decapsulation key is regenerated deterministically.
pub const MLKEM_SECRET_LEN: usize = 64;
// ---------- X25519 ----------
@@ -116,6 +118,22 @@ impl MlKemSecretKey {
.map_err(|_| CryptoError::malformed("ml-kem ciphertext"))?;
Ok(shared32(&self.0.decapsulate(&ct)))
}
/// Serialise to the 64-byte keygen seed. Guard like a private key.
#[must_use]
pub fn to_bytes(&self) -> [u8; MLKEM_SECRET_LEN] {
let seed = self.0.to_bytes();
let mut out = [0u8; MLKEM_SECRET_LEN];
out.copy_from_slice(&seed);
out
}
/// Regenerate from the 64-byte keygen seed.
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
let seed =
Seed::try_from(bytes).map_err(|_| CryptoError::malformed("ml-kem secret key"))?;
Ok(Self(DecapsulationKey::new(&seed)))
}
}
impl MlKemPublicKey {
@@ -188,6 +206,18 @@ mod tests {
assert!(MlKemPublicKey::from_slice(&bytes[..10]).is_err());
}
#[test]
fn mlkem_secret_key_roundtrips() {
let (dk, ek) = MlKemSecretKey::generate();
let bytes = dk.to_bytes();
assert_eq!(bytes.len(), MLKEM_SECRET_LEN);
let dk2 = MlKemSecretKey::from_bytes(&bytes).unwrap();
// The regenerated secret decapsulates ciphertexts to the same shared secret.
let (ct, ss) = ek.encapsulate();
assert_eq!(dk2.decapsulate(&ct).unwrap(), ss);
assert!(MlKemSecretKey::from_bytes(&bytes[..10]).is_err());
}
#[test]
fn mlkem_wrong_key_disagrees() {
let (dk_a, _ek_a) = MlKemSecretKey::generate();

View File

@@ -7,15 +7,27 @@
use crate::error::CryptoError;
use crate::identity::{IdentityKeyPair, IdentityPublicKey, IdentitySignature};
use crate::kem::{MlKemPublicKey, MlKemSecretKey, X25519PublicKey, X25519SecretKey};
use crate::kem::{
MLKEM_SECRET_LEN, MlKemPublicKey, MlKemSecretKey, X25519PublicKey, X25519SecretKey,
};
use crate::wire::{
Frame, TAG_CONTEXT, TAG_IDENTITY_PUB, TAG_MLKEM_EK, TAG_ONETIME_PREKEY, TAG_PREKEY_X25519,
TAG_SIGNATURE,
Frame, Reader, TAG_CONTEXT, TAG_IDENTITY_PUB, TAG_MLKEM_EK, TAG_ONETIME_PREKEY,
TAG_PREKEY_X25519, TAG_SIGNATURE,
};
/// Domain-separation label bound into every prekey signature.
const PREKEY_CONTEXT: &[u8] = b"buh-prekey-bundle-v1";
/// Version byte prefixing a serialised [`PrekeySecrets`] blob.
const SECRETS_VERSION: u8 = 1;
/// Read exactly 32 bytes from `r` into an array.
fn read_32(r: &mut Reader) -> Result<[u8; 32], CryptoError> {
Ok(r.read_bytes(32)?
.try_into()
.expect("read_bytes returns exactly 32"))
}
/// The secret half of a published [`PrekeyBundle`], held by the bundle's owner so they can
/// complete the handshake as the responder. Never leaves the device.
pub struct PrekeySecrets {
@@ -27,6 +39,46 @@ pub struct PrekeySecrets {
pub one_time_prekey: Option<X25519SecretKey>,
}
impl PrekeySecrets {
/// Serialise the secret halves to a versioned blob for the device key store. Contains
/// private keys — store sealed.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(1 + 32 + MLKEM_SECRET_LEN + 1 + 32);
out.push(SECRETS_VERSION);
out.extend_from_slice(&self.signed_prekey.to_bytes());
out.extend_from_slice(&self.kem_secret.to_bytes());
match &self.one_time_prekey {
Some(opk) => {
out.push(1);
out.extend_from_slice(&opk.to_bytes());
}
None => out.push(0),
}
out
}
/// Parse a blob produced by [`Self::to_bytes`].
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
let mut r = Reader::new(bytes);
if r.read_u8()? != SECRETS_VERSION {
return Err(CryptoError::malformed("prekey secrets version"));
}
let signed_prekey = X25519SecretKey::from_bytes(read_32(&mut r)?);
let kem_secret = MlKemSecretKey::from_bytes(r.read_bytes(MLKEM_SECRET_LEN)?)?;
let one_time_prekey = match r.read_u8()? {
0 => None,
1 => Some(X25519SecretKey::from_bytes(read_32(&mut r)?)),
_ => return Err(CryptoError::malformed("prekey secrets opk flag")),
};
Ok(Self {
signed_prekey,
kem_secret,
one_time_prekey,
})
}
}
/// A published, signed prekey bundle. Public; safe to hand out (it *is* the invite payload).
#[derive(Clone)]
pub struct PrekeyBundle {
@@ -157,6 +209,20 @@ mod tests {
}
}
#[test]
fn secrets_roundtrip() {
let id = IdentityKeyPair::generate();
let (secrets, bundle) = PrekeyBundle::generate(&id, true);
let parsed = PrekeySecrets::from_bytes(&secrets.to_bytes()).unwrap();
// Public halves match the published bundle…
assert_eq!(parsed.signed_prekey.public_key(), bundle.signed_prekey);
assert!(parsed.one_time_prekey.is_some());
// …and the restored KEM secret still decapsulates to the bundle's encapsulation key.
let (ct, ss) = bundle.kem_key.encapsulate();
assert_eq!(parsed.kem_secret.decapsulate(&ct).unwrap(), ss);
assert!(PrekeySecrets::from_bytes(b"\x02bad").is_err());
}
#[test]
fn tampered_prekey_is_rejected_on_parse() {
let id = IdentityKeyPair::generate();

View File

@@ -22,13 +22,29 @@ use header::read_u32;
use crate::aead;
use crate::error::CryptoError;
use crate::kem::{X25519PublicKey, X25519SecretKey};
use crate::wire::{Frame, TAG_CIPHERTEXT, TAG_RATCHET_DH, TAG_RATCHET_N, TAG_RATCHET_PN};
use crate::wire::{
Frame, Reader, TAG_CIPHERTEXT, TAG_RATCHET_DH, TAG_RATCHET_N, TAG_RATCHET_PN, write_varint,
};
/// Maximum messages that may be skipped within a single receiving chain before a gap is
/// treated as abuse rather than ordinary loss/reordering.
pub const MAX_SKIP: u32 = 1000;
/// Hard cap on retained skipped message keys across all chains (bounds memory under attack).
const MAX_STORED_SKIPPED: usize = 2000;
/// Version byte prefixing a serialised [`RatchetState`] blob.
const STATE_VERSION: u8 = 1;
/// Read exactly 32 bytes from `r` into an array.
fn read_32(r: &mut Reader) -> Result<[u8; 32], CryptoError> {
Ok(r.read_bytes(32)?
.try_into()
.expect("read_bytes returns exactly 32"))
}
/// Read a big-endian `u32` from a reader.
fn read_u32_from(r: &mut Reader) -> Result<u32, CryptoError> {
read_u32(r.read_bytes(4)?)
}
/// One end of a Double Ratchet session. Holds secret chain state; not `Clone`.
pub struct RatchetState {
@@ -87,6 +103,106 @@ impl RatchetState {
}
}
/// Serialise the entire session — keys, chains, counters, and banked skipped keys — to a
/// versioned blob for the device key store. **Contains all session secrets; store sealed.**
/// The public ratchet key is recomputed on load, not stored.
#[must_use]
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
out.push(STATE_VERSION);
out.extend_from_slice(&self.dhs.to_bytes());
let mut flags = 0u8;
if self.dhr.is_some() {
flags |= 0b001;
}
if self.cks.is_some() {
flags |= 0b010;
}
if self.ckr.is_some() {
flags |= 0b100;
}
out.push(flags);
if let Some(dhr) = &self.dhr {
out.extend_from_slice(&dhr.to_bytes());
}
out.extend_from_slice(&self.rk);
if let Some(cks) = &self.cks {
out.extend_from_slice(cks);
}
if let Some(ckr) = &self.ckr {
out.extend_from_slice(ckr);
}
out.extend_from_slice(&self.ns.to_be_bytes());
out.extend_from_slice(&self.nr.to_be_bytes());
out.extend_from_slice(&self.pn.to_be_bytes());
write_varint(&mut out, self.skipped.len() as u64);
// Sort for a deterministic encoding (HashMap order is not stable).
let mut entries: Vec<_> = self.skipped.iter().collect();
entries.sort_unstable_by_key(|((dh, n), _)| (*dh, *n));
for ((dh, n), mk) in entries {
out.extend_from_slice(dh);
out.extend_from_slice(&n.to_be_bytes());
out.extend_from_slice(mk);
}
out
}
/// Restore a session from a blob produced by [`Self::to_bytes`].
pub fn from_bytes(bytes: &[u8]) -> Result<Self, CryptoError> {
let mut r = Reader::new(bytes);
if r.read_u8()? != STATE_VERSION {
return Err(CryptoError::malformed("ratchet state version"));
}
let dhs = X25519SecretKey::from_bytes(read_32(&mut r)?);
let dhs_pub = dhs.public_key();
let flags = r.read_u8()?;
let dhr = if flags & 0b001 != 0 {
Some(X25519PublicKey::from_slice(&read_32(&mut r)?)?)
} else {
None
};
let rk = read_32(&mut r)?;
let cks = if flags & 0b010 != 0 {
Some(read_32(&mut r)?)
} else {
None
};
let ckr = if flags & 0b100 != 0 {
Some(read_32(&mut r)?)
} else {
None
};
let ns = read_u32_from(&mut r)?;
let nr = read_u32_from(&mut r)?;
let pn = read_u32_from(&mut r)?;
let count = r.read_varint()?;
if count > MAX_STORED_SKIPPED as u64 {
return Err(CryptoError::malformed("ratchet skipped-key count"));
}
let mut skipped = HashMap::new();
for _ in 0..count {
let dh = read_32(&mut r)?;
let n = read_u32_from(&mut r)?;
let mk = read_32(&mut r)?;
skipped.insert((dh, n), mk);
}
Ok(Self {
dhs,
dhs_pub,
dhr,
rk,
cks,
ckr,
ns,
nr,
pn,
skipped,
})
}
/// Encrypt `plaintext`, advancing the sending chain. Returns a self-describing wire
/// message (header + ciphertext). Fails if no sending chain exists yet (a responder that
/// has not received the first message).
@@ -274,6 +390,33 @@ mod tests {
assert_eq!(bob.decrypt(&m), Err(CryptoError::Aead));
}
#[test]
fn state_survives_serialization_mid_conversation() {
let (mut alice, mut bob) = session();
// Get a few turns in (so chains, counters, and a DH step are all non-trivial)…
let m1 = alice.encrypt(b"one").unwrap();
assert_eq!(bob.decrypt(&m1).unwrap(), b"one");
let r1 = bob.encrypt(b"two").unwrap();
assert_eq!(alice.decrypt(&r1).unwrap(), b"two");
// …and bank a skipped key on Bob's side (m2 sent, m3 delivered first).
let _m2 = alice.encrypt(b"skipped").unwrap();
let m3 = alice.encrypt(b"three").unwrap();
assert_eq!(bob.decrypt(&m3).unwrap(), b"three");
// Round-trip both ends through the persistence blob.
let mut alice = RatchetState::from_bytes(&alice.to_bytes()).unwrap();
let mut bob = RatchetState::from_bytes(&bob.to_bytes()).unwrap();
// Conversation continues seamlessly, and the banked skipped key still opens m2.
assert_eq!(bob.decrypt(&_m2).unwrap(), b"skipped");
let m4 = alice.encrypt(b"four").unwrap();
assert_eq!(bob.decrypt(&m4).unwrap(), b"four");
let r2 = bob.encrypt(b"five").unwrap();
assert_eq!(alice.decrypt(&r2).unwrap(), b"five");
assert!(RatchetState::from_bytes(b"\x02bad").is_err());
}
#[test]
fn replay_is_rejected() {
let (mut alice, mut bob) = session();

View File

@@ -0,0 +1,80 @@
//! End-to-end session flow over the *serialised* state blobs — exactly the handoff the WASM
//! facade and the web demo perform, but driven from the native library API so it runs in the
//! normal test suite. Proves that persisting and reloading every piece of state (identity
//! seed, prekey secrets, ratchet session) across each step still yields a working,
//! bidirectional, ratcheted conversation.
use buh_crypto::identity::IdentityKeyPair;
use buh_crypto::invite::{CA_FINGERPRINT_LEN, INVITE_NONCE_LEN, Invite, QueueDescriptor};
use buh_crypto::pqxdh::{InitialMessage, initiate, respond};
use buh_crypto::prekey::{PrekeyBundle, PrekeySecrets};
use buh_crypto::ratchet::RatchetState;
/// Reload a value through its byte blob, mimicking a KeyStore read between calls.
fn reload_session(blob: &[u8]) -> RatchetState {
RatchetState::from_bytes(blob).expect("session blob round-trips")
}
#[test]
fn invite_to_ratcheted_conversation_over_blobs() {
// --- Alice (inviter / responder) sets herself up and publishes an invite. ---
let alice_identity_seed = IdentityKeyPair::generate().to_seed();
let alice_id = IdentityKeyPair::from_seed(&alice_identity_seed);
let (alice_secrets, alice_bundle) = PrekeyBundle::generate(&alice_id, true);
let alice_secrets_blob = alice_secrets.to_bytes();
let alice_bundle_blob = alice_bundle.encode();
let queue = QueueDescriptor {
queue_id: [0x41; 32],
relay_url: "https://relay.test:8443".to_owned(),
ca_fingerprint: [0x42; CA_FINGERPRINT_LEN],
};
let invite_uri = Invite::create(
&alice_id,
queue.clone(),
PrekeyBundle::decode(&alice_bundle_blob).unwrap(),
[0x43; INVITE_NONCE_LEN],
4_000_000_000_000,
)
.to_uri();
// --- Bob (invitee / initiator) parses the invite and opens a session. ---
let parsed = Invite::parse(&invite_uri).expect("invite verifies");
assert_eq!(parsed.queue.queue_id, [0x41; 32]); // where Bob will deliver
let bob_identity_seed = IdentityKeyPair::generate().to_seed();
let bob_id = IdentityKeyPair::from_seed(&bob_identity_seed);
let (handshake, root_bob) = initiate(&bob_id, &parsed.bundle);
let initial_message_bytes = handshake.encode();
let mut bob_session = RatchetState::initiator(root_bob, parsed.bundle.signed_prekey);
let hello = bob_session.encrypt(b"hello alice").unwrap();
let bob_session_blob = bob_session.to_bytes(); // persisted
// Over the wire Bob delivers (initial_message_bytes, hello) to Alice's queue.
// --- Alice accepts from her stored secrets + bundle, and decrypts. ---
let alice_secrets = PrekeySecrets::from_bytes(&alice_secrets_blob).unwrap();
let alice_bundle = PrekeyBundle::decode(&alice_bundle_blob).unwrap();
let message = InitialMessage::decode(&initial_message_bytes).unwrap();
let root_alice = respond(&alice_bundle, &alice_secrets, &message).unwrap();
let mut alice_session = RatchetState::responder(root_alice, alice_secrets.signed_prekey);
assert_eq!(alice_session.decrypt(&hello).unwrap(), b"hello alice");
let alice_session_blob = alice_session.to_bytes(); // persisted
// --- Both sides reload from persisted blobs and continue a few turns. ---
let mut alice_session = reload_session(&alice_session_blob);
let mut bob_session = reload_session(&bob_session_blob);
let reply = alice_session.encrypt(b"hi bob").unwrap();
assert_eq!(bob_session.decrypt(&reply).unwrap(), b"hi bob");
for i in 0..3u8 {
// Persist/reload on every hop, as the KeyStore would, threading the advancing state.
bob_session = reload_session(&bob_session.to_bytes());
let m = bob_session.encrypt(&[i; 3]).unwrap();
alice_session = reload_session(&alice_session.to_bytes());
assert_eq!(alice_session.decrypt(&m).unwrap(), &[i; 3]);
let r = alice_session.encrypt(&[i + 50; 3]).unwrap();
assert_eq!(bob_session.decrypt(&r).unwrap(), &[i + 50; 3]);
}
}

14
web/package-lock.json generated
View File

@@ -8,6 +8,8 @@
"name": "buh-web",
"version": "0.1.0",
"dependencies": {
"hash-wasm": "^4.11.0",
"idb": "^8.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
@@ -1223,6 +1225,18 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hash-wasm": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz",
"integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==",
"license": "MIT"
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -10,6 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"hash-wasm": "^4.11.0",
"idb": "^8.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},

View File

@@ -1,75 +1,117 @@
import { useEffect, useState } from "react";
import {
aeadSelfTest,
echo,
identitySelfTest,
version,
wireSelfTest,
} from "./lib/crypto";
import { useEffect, useRef, useState } from "react";
import { version } from "./lib/crypto";
import { type DemoResult, runDemo } from "./lib/demo";
import { IndexedDbKeyStore } from "./lib/keystore";
import { health } from "./lib/relay";
interface Check {
name: string;
pass: boolean;
detail: string;
}
// Proves the Rust↔WASM boundary before any real session code depends on it: a Uint8Array
// must survive the round trip unchanged, and the crypto core's own KATs (wire codec,
// XChaCha20-Poly1305, ML-DSA-65 over the wasm_js getrandom backend) must pass in the browser.
function runChecks(): Check[] {
const input = new Uint8Array([0xb0, 0x01, 0x00, 0xff, 0x42]);
const output = echo(input);
const roundTripped =
output.length === input.length && output.every((b, i) => b === input[i]);
return [
{
name: "Uint8Array round-trips Rust↔WASM",
pass: roundTripped,
detail: `${[...input]}${[...output]}`,
},
{ name: "wire TLV codec KAT", pass: wireSelfTest(), detail: "encode/decode + PQ gate" },
{ name: "XChaCha20-Poly1305 KAT", pass: aeadSelfTest(), detail: "draft-arciszewski A.1" },
{
name: "ML-DSA-65 keygen/sign/verify",
pass: identitySelfTest(),
detail: "hedged signing, wasm_js RNG",
},
];
}
const mono: React.CSSProperties = { fontFamily: "ui-monospace, monospace" };
export default function App() {
const [checks, setChecks] = useState<Check[] | null>(null);
const [relayUp, setRelayUp] = useState<boolean | null>(null);
const [log, setLog] = useState<string[]>([]);
const [result, setResult] = useState<DemoResult | null>(null);
const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false);
const store = useRef<IndexedDbKeyStore | null>(null);
useEffect(() => {
try {
setChecks(runChecks());
} catch (e) {
setError(String(e));
}
health().then(setRelayUp);
}, []);
const allPass = checks?.every((c) => c.pass) ?? false;
async function run() {
setRunning(true);
setError(null);
setResult(null);
setLog([]);
try {
if (!store.current) {
store.current = await IndexedDbKeyStore.open("buh-demo-passphrase");
}
const append = (line: string) => setLog((l) => [...l, line]);
setResult(await runDemo(store.current, append));
} catch (e) {
setError(String(e));
} finally {
setRunning(false);
}
}
return (
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "3rem auto" }}>
<h1>buh crypto boundary</h1>
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 760, margin: "2.5rem auto", padding: "0 1rem" }}>
<h1>buh end-to-end through a blind relay</h1>
<p>
buh-crypto v{checks ? version() : "…"} {" "}
<strong style={{ color: allPass ? "green" : error ? "crimson" : "gray" }}>
{error ? "ERROR" : allPass ? "all checks pass" : "running…"}
buh-crypto v{version()} · relay{" "}
<strong style={{ color: relayUp ? "green" : relayUp === false ? "crimson" : "gray" }}>
{relayUp == null ? "checking…" : relayUp ? "reachable" : "unreachable (start buh-api)"}
</strong>
</p>
{error && <pre style={{ color: "crimson" }}>{error}</pre>}
<ul style={{ listStyle: "none", padding: 0 }}>
{checks?.map((c) => (
<li key={c.name} data-testid="check" data-pass={c.pass} style={{ padding: "0.35rem 0" }}>
<span style={{ color: c.pass ? "green" : "crimson" }}>{c.pass ? "✓" : "✗"}</span>{" "}
{c.name} <small style={{ color: "#666" }}> {c.detail}</small>
</li>
))}
</ul>
<p style={{ color: "#555" }}>
One page plays Alice and Bob. Alice publishes a signed invite; Bob verifies it, runs the
PQXDH handshake (X25519 + ML-KEM-768) and Double Ratchet, and a sealed text message
travels each way <strong>through the real relay</strong>. Every secret is persisted via an
Argon2id-sealed IndexedDB key store.
</p>
<button type="button" onClick={run} disabled={running || relayUp === false} style={{ padding: "0.5rem 1rem", fontSize: "1rem" }}>
{running ? "running…" : "Run end-to-end demo"}
</button>
{error && (
<pre style={{ color: "crimson", whiteSpace: "pre-wrap", marginTop: "1rem" }}>{error}</pre>
)}
{log.length > 0 && (
<ol style={{ color: "#444", fontSize: "0.9rem", marginTop: "1.25rem" }}>
{log.map((line, i) => (
<li key={`${i}-${line}`}>{line}</li>
))}
</ol>
)}
{result && (
<section style={{ marginTop: "1.5rem" }}>
<h2>Decrypted at each endpoint</h2>
<p>
Alice ({result.aliceFingerprint}) read:{" "}
<strong data-testid="alice-read">{result.aliceDecrypted}</strong>
</p>
<p>
Bob ({result.bobFingerprint}) read:{" "}
<strong data-testid="bob-read">{result.bobDecrypted}</strong>
</p>
<h2>What the relay stored (it is blind)</h2>
<p style={{ color: "#555", fontSize: "0.9rem" }}>
The relay holds only an opaque queue id, an envelope id, and ciphertext no identity,
no sender, nothing linking the two queues.
</p>
<table style={{ ...mono, fontSize: "0.8rem", borderCollapse: "collapse", width: "100%" }}>
<thead>
<tr style={{ textAlign: "left", borderBottom: "1px solid #ccc" }}>
<th>queue</th>
<th>envelope id</th>
<th>payload (sealed)</th>
<th>bytes</th>
</tr>
</thead>
<tbody>
{result.relayView.map((e) => (
<tr key={e.envelopeId} style={{ borderBottom: "1px solid #eee" }}>
<td>{e.queue}</td>
<td>{e.envelopeId.slice(0, 8)}</td>
<td>{e.payloadPreview}</td>
<td>{e.bytes}</td>
</tr>
))}
</tbody>
</table>
<h2>Invite</h2>
<p style={{ ...mono, fontSize: "0.75rem", wordBreak: "break-all", color: "#666" }}>
{result.inviteUri.slice(0, 120)}
</p>
</section>
)}
</main>
);
}

25
web/src/lib/bytes.ts Normal file
View File

@@ -0,0 +1,25 @@
// Small byte/encoding helpers shared by the relay client and demo.
/// Lowercase hex, as the relay expects queue ids in the URL path.
export function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
}
/// Standard (non-URL-safe) base64 — the encoding the relay uses for envelope payloads.
export function toBase64(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}
export function fromBase64(b64: string): Uint8Array {
const s = atob(b64);
const out = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
return out;
}
/// 32 cryptographically-random bytes — used here to mint opaque queue ids.
export function randomQueueId(): Uint8Array {
return crypto.getRandomValues(new Uint8Array(32));
}

View File

@@ -1,14 +1,29 @@
// Typed facade over the wasm-pack output of `buh-crypto`. The `pkg/` directory is generated
// (gitignored) by `npm run wasm`; everything in the app imports the crypto core through here,
// never from `pkg/` directly, so the real Phase-4 API (generateIdentity, createInvite, …) can
// grow behind this single seam.
// never from `pkg/` directly, so the surface stays small and swappable.
//
// Because the package is built `--target bundler`, importing these bindings instantiates the
// wasm module via a top-level await — no explicit init() call is needed.
export {
echo,
version,
// Phase-0 boundary self-tests (still surfaced as a diagnostics panel).
echo,
wire_self_test as wireSelfTest,
aead_self_test as aeadSelfTest,
identity_self_test as identitySelfTest,
// Session facade.
generate_identity as generateIdentity,
identity_public_key as identityPublicKey,
publishable_prekey_bundle as publishablePrekeyBundle,
create_invite as createInvite,
parse_invite as parseInvite,
initiate_session as initiateSession,
accept_session as acceptSession,
encrypt_message as encryptMessage,
decrypt_message as decryptMessage,
PrekeyMaterial,
ParsedInvite,
InitiatedSession,
EncryptedMessage,
DecryptedMessage,
} from "./pkg/buh_crypto";

146
web/src/lib/demo.ts Normal file
View File

@@ -0,0 +1,146 @@
// The Phase-4 milestone: a sealed 1:1 text message travelling Alice ↔ Bob through the real
// blind relay, end-to-end encrypted, with every secret persisted through the KeyStore.
//
// One page plays both parties (Alice and Bob), so the whole path is observable: identity →
// signed invite → PQXDH handshake → Double Ratchet, with the ciphertext going over the wire
// to `buh-api` and back. The relay only ever sees opaque payloads on opaque queues.
import {
acceptSession,
createInvite,
decryptMessage,
encryptMessage,
generateIdentity,
identityPublicKey,
initiateSession,
parseInvite,
publishablePrekeyBundle,
} from "./crypto";
import { randomQueueId, toBase64, toHex } from "./bytes";
import type { KeyStore } from "./keystore";
import * as relay from "./relay";
const enc = new TextEncoder();
const dec = new TextDecoder();
export interface RelayEnvelopeView {
queue: string;
envelopeId: string;
payloadPreview: string;
bytes: number;
}
export interface DemoResult {
inviteUri: string;
aliceFingerprint: string;
bobFingerprint: string;
aliceDecrypted: string;
bobDecrypted: string;
relayView: RelayEnvelopeView[];
}
const fingerprint = (idPub: Uint8Array) => toHex(idPub).slice(0, 16);
function view(queue: Uint8Array, e: relay.StoredEnvelope): RelayEnvelopeView {
return {
queue: toHex(queue).slice(0, 16),
envelopeId: e.envelope_id,
payloadPreview: `${toBase64(e.payload).slice(0, 44)}`,
bytes: e.payload.length,
};
}
export async function runDemo(
store: KeyStore,
log: (line: string) => void,
): Promise<DemoResult> {
const relayView: RelayEnvelopeView[] = [];
// --- Alice sets up an identity + prekey bundle and publishes an invite. ---
log("Alice: generating identity + prekey bundle…");
const aliceId = generateIdentity();
await store.put("alice/identity", aliceId);
const aliceMaterial = publishablePrekeyBundle(aliceId);
await store.put("alice/secrets", aliceMaterial.secrets);
await store.put("alice/bundle", aliceMaterial.bundle);
const aliceQueue = randomQueueId();
await store.put("alice/queue", aliceQueue);
const nonce = crypto.getRandomValues(new Uint8Array(16));
const caFingerprint = new Uint8Array(32); // real per-node CA pinning arrives in Phase 6
const inviteUri = createInvite(
aliceId,
aliceQueue,
"proxied:/v1",
caFingerprint,
aliceMaterial.bundle,
nonce,
Date.now() + 86_400_000,
);
log(`Alice: invite created (${inviteUri.length} chars).`);
// --- Bob receives the invite out-of-band, verifies it, and opens a session. ---
log("Bob: parsing + verifying invite…");
const parsed = parseInvite(inviteUri); // throws if the signature/bundle don't verify
const bobId = generateIdentity();
await store.put("bob/identity", bobId);
const bobQueue = randomQueueId();
await store.put("bob/queue", bobQueue);
log("Bob: PQXDH handshake (X25519 + ML-KEM-768) + first ratchet message…");
const initiated = initiateSession(bobId, parsed.bundle);
await store.put("bob/session", initiated.session);
const bobToAlice = encryptMessage(initiated.session, enc.encode("hello alice — bob here"));
await store.put("bob/session", bobToAlice.session);
// Bob delivers the handshake then the ciphertext to Alice's queue, through the relay.
log("Bob → relay: push handshake + ciphertext to Alice's queue…");
await relay.push(parsed.queue_id, initiated.initial_message);
await relay.push(parsed.queue_id, bobToAlice.message);
// --- Alice pulls from the relay, completes the handshake, and decrypts. ---
log("Alice ← relay: pull queue (long-poll)…");
const inbound = await relay.pull(aliceQueue, 5);
if (inbound.length < 2) throw new Error(`expected 2 envelopes, got ${inbound.length}`);
inbound.forEach((e) => relayView.push(view(aliceQueue, e)));
const aliceSecrets = await store.get("alice/secrets");
const aliceBundle = await store.get("alice/bundle");
if (!aliceSecrets || !aliceBundle) throw new Error("alice key material missing");
log("Alice: accept session + decrypt…");
let aliceSession = acceptSession(aliceSecrets, aliceBundle, inbound[0].payload);
const opened = decryptMessage(aliceSession, inbound[1].payload);
aliceSession = opened.session;
await store.put("alice/session", aliceSession);
const aliceDecrypted = dec.decode(opened.plaintext);
for (const e of inbound) await relay.ack(aliceQueue, e.envelope_id);
// --- Alice replies; Bob pulls and decrypts. ---
log("Alice → relay: push reply to Bob's queue…");
const aliceToBob = encryptMessage(aliceSession, enc.encode("hi bob — got it, sealed end-to-end"));
await store.put("alice/session", aliceToBob.session);
await relay.push(bobQueue, aliceToBob.message);
log("Bob ← relay: pull + decrypt reply…");
const reply = await relay.pull(bobQueue, 5);
if (reply.length < 1) throw new Error("expected Bob's reply");
reply.forEach((e) => relayView.push(view(bobQueue, e)));
const bobSession = await store.get("bob/session");
if (!bobSession) throw new Error("bob session missing");
const openedReply = decryptMessage(bobSession, reply[0].payload);
await store.put("bob/session", openedReply.session);
const bobDecrypted = dec.decode(openedReply.plaintext);
for (const e of reply) await relay.ack(bobQueue, e.envelope_id);
log("Done — message sealed end-to-end through the blind relay.");
return {
inviteUri,
aliceFingerprint: fingerprint(parsed.identity_public_key),
bobFingerprint: fingerprint(identityPublicKey(bobId)),
aliceDecrypted,
bobDecrypted,
relayView,
};
}

95
web/src/lib/keystore.ts Normal file
View File

@@ -0,0 +1,95 @@
// A device key store for the client's secret state blobs (identity seed, prekey secrets,
// ratchet sessions). Everything is sealed at rest: a passphrase is stretched with **Argon2id**
// into an AES-GCM key, and each blob is stored in IndexedDB as {iv, ciphertext}. The wasm
// facade is stateless, so this is where persistence lives — `IndexedDbKeyStore` now, a
// `TauriKeyStore` later behind the same interface (the same trait-swap discipline as the
// node's blob/settlement adapters).
import { argon2id } from "hash-wasm";
import { type IDBPDatabase, openDB } from "idb";
export interface KeyStore {
put(key: string, value: Uint8Array): Promise<void>;
get(key: string): Promise<Uint8Array | null>;
delete(key: string): Promise<void>;
}
const DB_NAME = "buh";
const SEALED = "sealed";
const META = "meta";
/// Copy bytes into a fresh, concrete `ArrayBuffer` — WebCrypto wants `BufferSource` backed by
/// `ArrayBuffer`, which the generic `Uint8Array<ArrayBufferLike>` from wasm/idb doesn't satisfy.
function ab(bytes: Uint8Array): ArrayBuffer {
const out = new ArrayBuffer(bytes.byteLength);
new Uint8Array(out).set(bytes);
return out;
}
interface SealedRecord {
iv: Uint8Array;
ct: Uint8Array;
}
export class IndexedDbKeyStore implements KeyStore {
private constructor(
private readonly db: IDBPDatabase,
private readonly aesKey: CryptoKey,
) {}
/// Open (or create) the store and derive its AES-GCM key from `passphrase` via Argon2id over
/// a persistent per-device salt.
static async open(passphrase: string): Promise<IndexedDbKeyStore> {
const db = await openDB(DB_NAME, 1, {
upgrade(database) {
database.createObjectStore(SEALED);
database.createObjectStore(META);
},
});
let salt = (await db.get(META, "salt")) as Uint8Array | undefined;
if (!salt) {
salt = crypto.getRandomValues(new Uint8Array(16));
await db.put(META, salt, "salt");
}
const raw = await argon2id({
password: passphrase,
salt,
parallelism: 1,
iterations: 3,
memorySize: 65536, // 64 MiB
hashLength: 32,
outputType: "binary",
});
const aesKey = await crypto.subtle.importKey("raw", ab(raw), "AES-GCM", false, [
"encrypt",
"decrypt",
]);
return new IndexedDbKeyStore(db, aesKey);
}
async put(key: string, value: Uint8Array): Promise<void> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(
await crypto.subtle.encrypt({ name: "AES-GCM", iv: ab(iv) }, this.aesKey, ab(value)),
);
const record: SealedRecord = { iv, ct };
await this.db.put(SEALED, record, key);
}
async get(key: string): Promise<Uint8Array | null> {
const record = (await this.db.get(SEALED, key)) as SealedRecord | undefined;
if (!record) return null;
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: ab(record.iv) },
this.aesKey,
ab(record.ct),
);
return new Uint8Array(pt);
}
async delete(key: string): Promise<void> {
await this.db.delete(SEALED, key);
}
}

58
web/src/lib/relay.ts Normal file
View File

@@ -0,0 +1,58 @@
// Thin client for the Phase-1 blind relay (`/v1`). The relay sees only opaque payloads keyed
// by a 32-byte queue id — no identity, no sender. Possession of the queue id is the entire
// capability. Requests go same-origin and are proxied to `buh-api` by Vite (see vite.config).
import { fromBase64, toBase64, toHex } from "./bytes";
export interface StoredEnvelope {
envelope_id: string;
payload: Uint8Array;
received_at: string;
expires_at: string;
}
const DEFAULT_TTL_SECONDS = 3600;
/// Push a sealed envelope to a queue. Returns the relay's envelope id.
export async function push(queueId: Uint8Array, payload: Uint8Array): Promise<string> {
const res = await fetch(`/v1/queue/${toHex(queueId)}/envelopes`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ payload: toBase64(payload), ttl_seconds: DEFAULT_TTL_SECONDS }),
});
if (!res.ok) throw new Error(`relay push failed: ${res.status}`);
return (await res.json()).envelope_id;
}
/// Pull live envelopes (oldest first), optionally long-polling up to `waitSeconds`.
export async function pull(queueId: Uint8Array, waitSeconds = 0): Promise<StoredEnvelope[]> {
const q = waitSeconds > 0 ? `?wait=${waitSeconds}` : "";
const res = await fetch(`/v1/queue/${toHex(queueId)}/envelopes${q}`);
if (!res.ok) throw new Error(`relay pull failed: ${res.status}`);
const body = await res.json();
return body.envelopes.map((e: { envelope_id: string; payload: string; received_at: string; expires_at: string }) => ({
envelope_id: e.envelope_id,
payload: fromBase64(e.payload),
received_at: e.received_at,
expires_at: e.expires_at,
}));
}
/// Acknowledge delivery, removing the envelope from the live queue.
export async function ack(queueId: Uint8Array, envelopeId: string): Promise<boolean> {
const res = await fetch(`/v1/queue/${toHex(queueId)}/envelopes/${envelopeId}/ack`, {
method: "POST",
});
if (!res.ok) throw new Error(`relay ack failed: ${res.status}`);
return (await res.json()).acknowledged;
}
/// Whether the relay is reachable.
export async function health(): Promise<boolean> {
try {
const res = await fetch("/v1/health");
return res.ok;
} catch {
return false;
}
}

View File

@@ -6,6 +6,13 @@ import wasm from "vite-plugin-wasm";
// buh-crypto is a wasm-pack `--target bundler` module: it imports the `.wasm` as an ES module
// and instantiates it with a top-level await. `vite-plugin-wasm` resolves the wasm import and
// `vite-plugin-top-level-await` lets the resulting TLA work in browsers that need it.
//
// `/v1` is proxied to a locally-running `buh-api` relay so the browser talks to it same-origin
// (no CORS, no relay changes). Adjust the target if the relay binds elsewhere.
const RELAY = "http://127.0.0.1:8080";
export default defineConfig({
plugins: [react(), wasm(), topLevelAwait()],
server: { proxy: { "/v1": { target: RELAY, changeOrigin: true } } },
preview: { proxy: { "/v1": { target: RELAY, changeOrigin: true } } },
});