Compare commits
2 Commits
87853b96bc
...
a825c77424
| Author | SHA1 | Date | |
|---|---|---|---|
|
a825c77424
|
|||
|
e33ce20495
|
@@ -1,15 +1,37 @@
|
|||||||
//! The `wasm-bindgen` FFI surface, compiled only under the `wasm` feature for the web client.
|
//! 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
|
//! Two layers live here. The `*_self_test` helpers run the native KATs *inside the browser* so
|
||||||
//! correctly, and the `*_self_test` helpers run the native KATs *inside the browser* so the
|
//! the web app can confirm the whole crypto stack (TLV codec, XChaCha20-Poly1305, ML-DSA-65
|
||||||
//! web app can confirm the entire crypto stack — the TLV codec, XChaCha20-Poly1305, and
|
//! over the `wasm_js` getrandom backend) behaves identically to native. On top of that is the
|
||||||
//! ML-DSA-65 keygen/sign/verify over the `wasm_js` getrandom backend — behaves identically to
|
//! **session facade**: a small, envelope-oriented, *state-returning* API. Every mutating call
|
||||||
//! native before any real session code depends on it. The full envelope-oriented facade
|
//! takes the relevant opaque state blob(s) and returns new one(s); the Rust side keeps no
|
||||||
//! (`generateIdentity`, `createInvite`, …) lands in Phase 4.
|
//! 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 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.
|
/// The crate version, so the bundle can assert it loaded the expected build.
|
||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
@@ -75,3 +97,272 @@ pub fn identity_self_test() -> bool {
|
|||||||
let sig = id.sign(msg);
|
let sig = id.sign(msg);
|
||||||
pk.verify(msg, &sig).is_ok() && pk.verify(b"tampered", &sig).is_err()
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
//! Two independent primitives live here; combining them into a root key is `pqxdh`'s job.
|
//! 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).
|
//! Randomness comes from the system RNG (getrandom; `wasm_js` in the browser).
|
||||||
|
|
||||||
use ml_kem::Kem;
|
use ml_kem::kem::{Decapsulate, Encapsulate, KeyExport, KeyInit};
|
||||||
use ml_kem::kem::{Decapsulate, Encapsulate, KeyExport};
|
|
||||||
use ml_kem::ml_kem_768::{Ciphertext, DecapsulationKey, EncapsulationKey};
|
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};
|
use x25519_dalek::{PublicKey as XPublic, StaticSecret as XSecret};
|
||||||
|
|
||||||
/// Copy a 32-byte shared-secret array out of an `ml-kem` `Array`.
|
/// 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;
|
pub const MLKEM_ENCAPS_KEY_LEN: usize = 1184;
|
||||||
/// Length of an ML-KEM-768 ciphertext (bytes).
|
/// Length of an ML-KEM-768 ciphertext (bytes).
|
||||||
pub const MLKEM_CIPHERTEXT_LEN: usize = 1088;
|
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 ----------
|
// ---------- X25519 ----------
|
||||||
|
|
||||||
@@ -116,6 +118,22 @@ impl MlKemSecretKey {
|
|||||||
.map_err(|_| CryptoError::malformed("ml-kem ciphertext"))?;
|
.map_err(|_| CryptoError::malformed("ml-kem ciphertext"))?;
|
||||||
Ok(shared32(&self.0.decapsulate(&ct)))
|
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 {
|
impl MlKemPublicKey {
|
||||||
@@ -188,6 +206,18 @@ mod tests {
|
|||||||
assert!(MlKemPublicKey::from_slice(&bytes[..10]).is_err());
|
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]
|
#[test]
|
||||||
fn mlkem_wrong_key_disagrees() {
|
fn mlkem_wrong_key_disagrees() {
|
||||||
let (dk_a, _ek_a) = MlKemSecretKey::generate();
|
let (dk_a, _ek_a) = MlKemSecretKey::generate();
|
||||||
|
|||||||
@@ -7,15 +7,27 @@
|
|||||||
|
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
use crate::identity::{IdentityKeyPair, IdentityPublicKey, IdentitySignature};
|
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::{
|
use crate::wire::{
|
||||||
Frame, TAG_CONTEXT, TAG_IDENTITY_PUB, TAG_MLKEM_EK, TAG_ONETIME_PREKEY, TAG_PREKEY_X25519,
|
Frame, Reader, TAG_CONTEXT, TAG_IDENTITY_PUB, TAG_MLKEM_EK, TAG_ONETIME_PREKEY,
|
||||||
TAG_SIGNATURE,
|
TAG_PREKEY_X25519, TAG_SIGNATURE,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Domain-separation label bound into every prekey signature.
|
/// Domain-separation label bound into every prekey signature.
|
||||||
const PREKEY_CONTEXT: &[u8] = b"buh-prekey-bundle-v1";
|
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
|
/// 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.
|
/// complete the handshake as the responder. Never leaves the device.
|
||||||
pub struct PrekeySecrets {
|
pub struct PrekeySecrets {
|
||||||
@@ -27,6 +39,46 @@ pub struct PrekeySecrets {
|
|||||||
pub one_time_prekey: Option<X25519SecretKey>,
|
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).
|
/// A published, signed prekey bundle. Public; safe to hand out (it *is* the invite payload).
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct PrekeyBundle {
|
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]
|
#[test]
|
||||||
fn tampered_prekey_is_rejected_on_parse() {
|
fn tampered_prekey_is_rejected_on_parse() {
|
||||||
let id = IdentityKeyPair::generate();
|
let id = IdentityKeyPair::generate();
|
||||||
|
|||||||
@@ -22,13 +22,29 @@ use header::read_u32;
|
|||||||
use crate::aead;
|
use crate::aead;
|
||||||
use crate::error::CryptoError;
|
use crate::error::CryptoError;
|
||||||
use crate::kem::{X25519PublicKey, X25519SecretKey};
|
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
|
/// Maximum messages that may be skipped within a single receiving chain before a gap is
|
||||||
/// treated as abuse rather than ordinary loss/reordering.
|
/// treated as abuse rather than ordinary loss/reordering.
|
||||||
pub const MAX_SKIP: u32 = 1000;
|
pub const MAX_SKIP: u32 = 1000;
|
||||||
/// Hard cap on retained skipped message keys across all chains (bounds memory under attack).
|
/// Hard cap on retained skipped message keys across all chains (bounds memory under attack).
|
||||||
const MAX_STORED_SKIPPED: usize = 2000;
|
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`.
|
/// One end of a Double Ratchet session. Holds secret chain state; not `Clone`.
|
||||||
pub struct RatchetState {
|
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
|
/// Encrypt `plaintext`, advancing the sending chain. Returns a self-describing wire
|
||||||
/// message (header + ciphertext). Fails if no sending chain exists yet (a responder that
|
/// message (header + ciphertext). Fails if no sending chain exists yet (a responder that
|
||||||
/// has not received the first message).
|
/// has not received the first message).
|
||||||
@@ -274,6 +390,33 @@ mod tests {
|
|||||||
assert_eq!(bob.decrypt(&m), Err(CryptoError::Aead));
|
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]
|
#[test]
|
||||||
fn replay_is_rejected() {
|
fn replay_is_rejected() {
|
||||||
let (mut alice, mut bob) = session();
|
let (mut alice, mut bob) = session();
|
||||||
|
|||||||
80
crates/buh-crypto/tests/session_flow.rs
Normal file
80
crates/buh-crypto/tests/session_flow.rs
Normal 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
14
web/package-lock.json
generated
@@ -8,6 +8,8 @@
|
|||||||
"name": "buh-web",
|
"name": "buh-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hash-wasm": "^4.11.0",
|
||||||
|
"idb": "^8.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
@@ -1223,6 +1225,18 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"hash-wasm": "^4.11.0",
|
||||||
|
"idb": "^8.0.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
|
|||||||
158
web/src/App.tsx
158
web/src/App.tsx
@@ -1,75 +1,117 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import {
|
import { version } from "./lib/crypto";
|
||||||
aeadSelfTest,
|
import { type DemoResult, runDemo } from "./lib/demo";
|
||||||
echo,
|
import { IndexedDbKeyStore } from "./lib/keystore";
|
||||||
identitySelfTest,
|
import { health } from "./lib/relay";
|
||||||
version,
|
|
||||||
wireSelfTest,
|
|
||||||
} from "./lib/crypto";
|
|
||||||
|
|
||||||
interface Check {
|
const mono: React.CSSProperties = { fontFamily: "ui-monospace, monospace" };
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
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 [error, setError] = useState<string | null>(null);
|
||||||
|
const [running, setRunning] = useState(false);
|
||||||
|
const store = useRef<IndexedDbKeyStore | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
health().then(setRelayUp);
|
||||||
setChecks(runChecks());
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 640, margin: "3rem auto" }}>
|
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 760, margin: "2.5rem auto", padding: "0 1rem" }}>
|
||||||
<h1>buh crypto boundary</h1>
|
<h1>buh — end-to-end through a blind relay</h1>
|
||||||
<p>
|
<p>
|
||||||
buh-crypto v{checks ? version() : "…"} —{" "}
|
buh-crypto v{version()} · relay{" "}
|
||||||
<strong style={{ color: allPass ? "green" : error ? "crimson" : "gray" }}>
|
<strong style={{ color: relayUp ? "green" : relayUp === false ? "crimson" : "gray" }}>
|
||||||
{error ? "ERROR" : allPass ? "all checks pass" : "running…"}
|
{relayUp == null ? "checking…" : relayUp ? "reachable" : "unreachable (start buh-api)"}
|
||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
{error && <pre style={{ color: "crimson" }}>{error}</pre>}
|
<p style={{ color: "#555" }}>
|
||||||
<ul style={{ listStyle: "none", padding: 0 }}>
|
One page plays Alice and Bob. Alice publishes a signed invite; Bob verifies it, runs the
|
||||||
{checks?.map((c) => (
|
PQXDH handshake (X25519 + ML-KEM-768) and Double Ratchet, and a sealed text message
|
||||||
<li key={c.name} data-testid="check" data-pass={c.pass} style={{ padding: "0.35rem 0" }}>
|
travels each way <strong>through the real relay</strong>. Every secret is persisted via an
|
||||||
<span style={{ color: c.pass ? "green" : "crimson" }}>{c.pass ? "✓" : "✗"}</span>{" "}
|
Argon2id-sealed IndexedDB key store.
|
||||||
{c.name} <small style={{ color: "#666" }}>— {c.detail}</small>
|
</p>
|
||||||
</li>
|
|
||||||
|
<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>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
web/src/lib/bytes.ts
Normal file
25
web/src/lib/bytes.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -1,14 +1,29 @@
|
|||||||
// Typed facade over the wasm-pack output of `buh-crypto`. The `pkg/` directory is generated
|
// 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,
|
// (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
|
// never from `pkg/` directly, so the surface stays small and swappable.
|
||||||
// grow behind this single seam.
|
|
||||||
//
|
//
|
||||||
// Because the package is built `--target bundler`, importing these bindings instantiates the
|
// 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.
|
// wasm module via a top-level await — no explicit init() call is needed.
|
||||||
export {
|
export {
|
||||||
echo,
|
|
||||||
version,
|
version,
|
||||||
|
// Phase-0 boundary self-tests (still surfaced as a diagnostics panel).
|
||||||
|
echo,
|
||||||
wire_self_test as wireSelfTest,
|
wire_self_test as wireSelfTest,
|
||||||
aead_self_test as aeadSelfTest,
|
aead_self_test as aeadSelfTest,
|
||||||
identity_self_test as identitySelfTest,
|
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";
|
} from "./pkg/buh_crypto";
|
||||||
|
|||||||
146
web/src/lib/demo.ts
Normal file
146
web/src/lib/demo.ts
Normal 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
95
web/src/lib/keystore.ts
Normal 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
58
web/src/lib/relay.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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
|
// 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.
|
// `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({
|
export default defineConfig({
|
||||||
plugins: [react(), wasm(), topLevelAwait()],
|
plugins: [react(), wasm(), topLevelAwait()],
|
||||||
|
server: { proxy: { "/v1": { target: RELAY, changeOrigin: true } } },
|
||||||
|
preview: { proxy: { "/v1": { target: RELAY, changeOrigin: true } } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user