From 223ea963ebd524aef3d8789e434f0c1a99caf6fc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 8 Oct 2026 06:21:37 +0300 Subject: [PATCH 01/17] feat(recordstore): fenced, sealed record storage for Signal state Add wacore-recordstore: a byte-level RecordStore and LeaseStore that a database adapter implements, and RecordSignalStore, which builds the full SignalStore on top of them. Every write carries the fence of the lease that authorised it and is applied only while that generation is current, so a node that lost its lease cannot overwrite its successor. Every value is sealed by a Sealer (AES-256-GCM by default) with scope, namespace and key in the AAD, so a record cannot be moved to another slot. Prekey upload marks use an update-only write and never resurrect a consumed key. Includes a conformance suite for adapters, the in-memory reference store, and a wasm32 CI build. --- .github/workflows/wasm.yml | 9 + Cargo.lock | 13 + Cargo.toml | 2 + wacore/recordstore/Cargo.toml | 26 ++ wacore/recordstore/src/conformance.rs | 385 ++++++++++++++++++ wacore/recordstore/src/lib.rs | 35 ++ wacore/recordstore/src/memory.rs | 191 +++++++++ wacore/recordstore/src/record.rs | 158 +++++++ wacore/recordstore/src/seal.rs | 157 +++++++ wacore/recordstore/src/signal.rs | 566 ++++++++++++++++++++++++++ 10 files changed, 1542 insertions(+) create mode 100644 wacore/recordstore/Cargo.toml create mode 100644 wacore/recordstore/src/conformance.rs create mode 100644 wacore/recordstore/src/lib.rs create mode 100644 wacore/recordstore/src/memory.rs create mode 100644 wacore/recordstore/src/record.rs create mode 100644 wacore/recordstore/src/seal.rs create mode 100644 wacore/recordstore/src/signal.rs diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index fc863ee1..c3dc54ae 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -86,6 +86,15 @@ jobs: # r2d2's pool and tokio's `spawn_blocking` both panic on a page, and both # compile there, so nothing but this check keeps them from creeping back into # the store once the shim exists. + # The fenced, sealed record store backs a browser-held Signal store, so + # it must keep building for the page as well as the server. + - name: Build wacore-recordstore for wasm32 (release) + env: + RUSTFLAGS: '--cfg getrandom_backend="wasm_js"' + run: > + cargo build -p wacore-recordstore --lib --release + --target wasm32-unknown-unknown + --features wacore/js - name: Guard sqlite-storage's platform shim run: cargo xt ci workflow guard-sqlite-shim # The sans-IO VoIP core (wacore::voip: the CallEngine, the portable run_call driver, diff --git a/Cargo.lock b/Cargo.lock index 6e914988..601eb2d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5323,6 +5323,19 @@ dependencies = [ "waproto", ] +[[package]] +name = "wacore-recordstore" +version = "0.1.0" +dependencies = [ + "aes-gcm 0.11.1", + "async-trait", + "bytes", + "futures", + "rand 0.10.3", + "thiserror 2.0.21", + "wacore", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index ffc58df3..c33e8196 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ members = [ "wacore/derive", "wacore/libsignal", "wacore/noise", + "wacore/recordstore", "waproto", ] default-members = [ @@ -55,6 +56,7 @@ default-members = [ "wacore/derive", "wacore/libsignal", "wacore/noise", + "wacore/recordstore", "waproto", ] diff --git a/wacore/recordstore/Cargo.toml b/wacore/recordstore/Cargo.toml new file mode 100644 index 00000000..856e6704 --- /dev/null +++ b/wacore/recordstore/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "wacore-recordstore" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +authors = ["Polymorfa"] +license = "MIT" +repository = "https://github.com/polymorfa/whatsapp-rust" +description = "Fenced, sealed record storage for wacore Signal state" +publish = false + +[features] +# Reusable checks every RecordStore implementation must pass. Adapter crates +# enable it in their dev-dependencies and call `conformance::run_all`. +conformance = [] + +[dependencies] +aes-gcm = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +rand = { workspace = true } +thiserror = { workspace = true } +wacore = { workspace = true } + +[dev-dependencies] +futures = { workspace = true, features = ["executor"] } diff --git a/wacore/recordstore/src/conformance.rs b/wacore/recordstore/src/conformance.rs new file mode 100644 index 00000000..80881895 --- /dev/null +++ b/wacore/recordstore/src/conformance.rs @@ -0,0 +1,385 @@ +//! Behaviour every [`RecordStore`] + [`LeaseStore`] adapter must show. +//! +//! Adapter crates enable the `conformance` feature in dev-dependencies and run +//! [`run_all`] against a real database or emulator. Each check uses its own +//! scope with a random suffix, so the suite can share a database with other +//! tests and with earlier runs. + +use crate::record::{Fence, LeaseStore, Namespace, RecordStore, WriteOp, is_fence_lost}; +use rand::Rng; + +const TTL: u64 = 10_000; + +/// Run every check. Panics with the failing check's name. +pub async fn run_all(store: &S) { + let run = format!("{:08x}", rand::rng().next_u32()); + leases_exclude_other_holders_until_expiry(store, &run).await; + same_holder_keeps_its_generation(store, &run).await; + renew_fails_after_takeover(store, &run).await; + release_advances_the_next_generation(store, &run).await; + writes_need_the_current_fence(store, &run).await; + a_stale_batch_applies_nothing(store, &run).await; + a_batch_applies_every_op(store, &run).await; + update_never_inserts(store, &run).await; + scan_orders_filters_and_limits(store, &run).await; + scopes_and_namespaces_are_isolated(store, &run).await; + get_many_returns_only_existing_keys(store, &run).await; +} + +fn scope(run: &str, check: &str) -> String { + format!("conformance-{run}-{check}") +} + +fn put(ns: Namespace, key: &str, value: &[u8]) -> WriteOp { + WriteOp::Put { + ns, + key: key.to_owned(), + value: value.to_vec(), + } +} + +async fn take(store: &S, scope: &str, holder: &str, now: u64) -> Fence { + store + .acquire(scope, holder, now, TTL) + .await + .expect("acquire") + .unwrap_or_else(|| panic!("{holder} could not acquire {scope}")) + .fence +} + +pub async fn leases_exclude_other_holders_until_expiry(store: &S, run: &str) { + let scope = scope(run, "exclusive"); + let first = take(store, &scope, "a", 1_000).await; + assert!( + store + .acquire(&scope, "b", 1_000 + TTL - 1, TTL) + .await + .unwrap() + .is_none(), + "leases_exclude_other_holders_until_expiry: b took a live lease" + ); + let second = take(store, &scope, "b", 1_000 + TTL).await; + assert!( + second.generation > first.generation, + "leases_exclude_other_holders_until_expiry: generation did not advance" + ); +} + +pub async fn same_holder_keeps_its_generation(store: &S, run: &str) { + let scope = scope(run, "same-holder"); + let first = take(store, &scope, "a", 1_000).await; + let again = take(store, &scope, "a", 2_000).await; + assert_eq!( + first, again, + "same_holder_keeps_its_generation: re-acquire changed the fence" + ); +} + +pub async fn renew_fails_after_takeover(store: &S, run: &str) { + let scope = scope(run, "renew"); + let lease = store + .acquire(&scope, "a", 1_000, TTL) + .await + .unwrap() + .unwrap(); + let renewed = store.renew(&lease, 2_000, TTL).await.unwrap(); + assert!( + renewed.is_some(), + "renew_fails_after_takeover: live renew failed" + ); + take(store, &scope, "b", 2_000 + TTL).await; + assert!( + store + .renew(&lease, 2_000 + TTL, TTL) + .await + .unwrap() + .is_none(), + "renew_fails_after_takeover: lost lease renewed" + ); +} + +pub async fn release_advances_the_next_generation( + store: &S, + run: &str, +) { + let scope = scope(run, "release"); + let lease = store + .acquire(&scope, "a", 1_000, TTL) + .await + .unwrap() + .unwrap(); + store.release(&lease).await.unwrap(); + let next = take(store, &scope, "b", 1_001).await; + assert!( + next.generation > lease.fence.generation, + "release_advances_the_next_generation: b reused a's generation" + ); + let stale = store + .write(&lease.fence, &[put(Namespace::Session, "k", b"v")]) + .await; + assert!( + stale.as_ref().is_err_and(is_fence_lost), + "release_advances_the_next_generation: released fence still wrote" + ); +} + +pub async fn writes_need_the_current_fence(store: &S, run: &str) { + let scope = scope(run, "fence"); + let never_leased = Fence { + scope: scope.as_str().into(), + generation: 1, + }; + let refused = store + .write(&never_leased, &[put(Namespace::Session, "k", b"v")]) + .await; + assert!( + refused.as_ref().is_err_and(is_fence_lost), + "writes_need_the_current_fence: wrote without any lease" + ); + let a = take(store, &scope, "a", 1_000).await; + store + .write(&a, &[put(Namespace::Session, "k", b"from-a")]) + .await + .expect("writes_need_the_current_fence: holder write failed"); + let b = take(store, &scope, "b", 1_000 + TTL).await; + let late = store + .write(&a, &[put(Namespace::Session, "k", b"late-a")]) + .await; + assert!( + late.as_ref().is_err_and(is_fence_lost), + "writes_need_the_current_fence: superseded holder wrote" + ); + store + .write(&b, &[put(Namespace::Session, "k", b"from-b")]) + .await + .unwrap(); + assert_eq!( + store + .get(&scope, Namespace::Session, "k") + .await + .unwrap() + .as_deref(), + Some(&b"from-b"[..]), + "writes_need_the_current_fence: wrong final value" + ); +} + +pub async fn a_stale_batch_applies_nothing(store: &S, run: &str) { + let scope = scope(run, "stale-batch"); + let a = take(store, &scope, "a", 1_000).await; + store + .write(&a, &[put(Namespace::Session, "keep", b"original")]) + .await + .unwrap(); + take(store, &scope, "b", 1_000 + TTL).await; + let _ = store + .write( + &a, + &[ + put(Namespace::Session, "new", b"x"), + put(Namespace::Session, "keep", b"overwritten"), + WriteOp::Delete { + ns: Namespace::Session, + key: "keep".into(), + }, + ], + ) + .await; + assert_eq!( + store + .get(&scope, Namespace::Session, "keep") + .await + .unwrap() + .as_deref(), + Some(&b"original"[..]), + "a_stale_batch_applies_nothing: stale batch changed a record" + ); + assert!( + store + .get(&scope, Namespace::Session, "new") + .await + .unwrap() + .is_none(), + "a_stale_batch_applies_nothing: stale batch inserted a record" + ); +} + +pub async fn a_batch_applies_every_op(store: &S, run: &str) { + let scope = scope(run, "batch"); + let fence = take(store, &scope, "a", 1_000).await; + store + .write( + &fence, + &[ + put(Namespace::PreKey, "1", b"one"), + put(Namespace::PreKey, "2", b"two"), + ], + ) + .await + .unwrap(); + store + .write( + &fence, + &[ + WriteOp::Delete { + ns: Namespace::PreKey, + key: "1".into(), + }, + WriteOp::Update { + ns: Namespace::PreKey, + key: "2".into(), + value: b"two-updated".to_vec(), + }, + put(Namespace::PreKey, "3", b"three"), + ], + ) + .await + .unwrap(); + assert!( + store + .get(&scope, Namespace::PreKey, "1") + .await + .unwrap() + .is_none() + ); + assert_eq!( + store + .get(&scope, Namespace::PreKey, "2") + .await + .unwrap() + .as_deref(), + Some(&b"two-updated"[..]), + "a_batch_applies_every_op: update missing" + ); + assert_eq!( + store + .get(&scope, Namespace::PreKey, "3") + .await + .unwrap() + .as_deref(), + Some(&b"three"[..]), + "a_batch_applies_every_op: put missing" + ); +} + +pub async fn update_never_inserts(store: &S, run: &str) { + let scope = scope(run, "update"); + let fence = take(store, &scope, "a", 1_000).await; + store + .write( + &fence, + &[WriteOp::Update { + ns: Namespace::PreKey, + key: "gone".into(), + value: b"x".to_vec(), + }], + ) + .await + .unwrap(); + assert!( + store + .get(&scope, Namespace::PreKey, "gone") + .await + .unwrap() + .is_none(), + "update_never_inserts: update resurrected a missing key" + ); +} + +pub async fn scan_orders_filters_and_limits(store: &S, run: &str) { + let scope = scope(run, "scan"); + let fence = take(store, &scope, "a", 1_000).await; + let keys = ["user:2@s", "user@s", "user:1@s", "other@s", "usera@s"]; + let ops: Vec<_> = keys + .iter() + .map(|k| put(Namespace::Session, k, b"v")) + .collect(); + store.write(&fence, &ops).await.unwrap(); + assert_eq!( + store + .scan_keys(&scope, Namespace::Session, "user:", None) + .await + .unwrap(), + vec!["user:1@s".to_owned(), "user:2@s".to_owned()], + "scan_orders_filters_and_limits: prefix scan" + ); + assert_eq!( + store + .scan_keys(&scope, Namespace::Session, "", Some(2)) + .await + .unwrap(), + vec!["other@s".to_owned(), "user:1@s".to_owned()], + "scan_orders_filters_and_limits: limited full scan" + ); +} + +pub async fn scopes_and_namespaces_are_isolated(store: &S, run: &str) { + let first = scope(run, "iso-1"); + let second = scope(run, "iso-1x"); + let a = take(store, &first, "a", 1_000).await; + let b = take(store, &second, "a", 1_000).await; + store + .write(&a, &[put(Namespace::Session, "k", b"first-session")]) + .await + .unwrap(); + store + .write(&b, &[put(Namespace::Session, "k", b"second-session")]) + .await + .unwrap(); + assert!( + store + .get(&first, Namespace::Identity, "k") + .await + .unwrap() + .is_none(), + "scopes_and_namespaces_are_isolated: namespace leaked" + ); + assert_eq!( + store + .get(&first, Namespace::Session, "k") + .await + .unwrap() + .as_deref(), + Some(&b"first-session"[..]), + "scopes_and_namespaces_are_isolated: scope leaked" + ); + assert_eq!( + store + .scan_keys(&first, Namespace::Session, "", None) + .await + .unwrap(), + vec!["k".to_owned()], + "scopes_and_namespaces_are_isolated: scan crossed into a sibling scope" + ); +} + +pub async fn get_many_returns_only_existing_keys( + store: &S, + run: &str, +) { + let scope = scope(run, "get-many"); + let fence = take(store, &scope, "a", 1_000).await; + store + .write( + &fence, + &[ + put(Namespace::SenderKey, "x", b"1"), + put(Namespace::SenderKey, "y", b"2"), + ], + ) + .await + .unwrap(); + let mut found = store + .get_many(&scope, Namespace::SenderKey, &["x", "missing", "y"]) + .await + .unwrap(); + found.sort(); + assert_eq!( + found, + vec![ + ("x".to_owned(), b"1".to_vec()), + ("y".to_owned(), b"2".to_vec()) + ], + "get_many_returns_only_existing_keys" + ); +} diff --git a/wacore/recordstore/src/lib.rs b/wacore/recordstore/src/lib.rs new file mode 100644 index 00000000..5532acaa --- /dev/null +++ b/wacore/recordstore/src/lib.rs @@ -0,0 +1,35 @@ +//! Fenced, sealed record storage for wacore Signal state. +//! +//! A database adapter implements two small traits, [`RecordStore`] and +//! [`LeaseStore`], over opaque byte records. [`RecordSignalStore`] builds the +//! full [`wacore::store::traits::SignalStore`] on top of them, so Signal +//! crypto keeps using unmodified wacore code. +//! +//! Two guarantees come from this layer rather than from each adapter: +//! +//! - **Fencing.** Every write carries the [`Fence`] of the lease that +//! authorised it. The adapter applies the batch only while that lease +//! generation is still current, in the same transaction. A node that lost +//! its lease can never overwrite state written by its successor. +//! - **Sealing.** Every value is sealed by a [`Sealer`] before it reaches the +//! adapter. The authenticated data binds scope, namespace and key, so a +//! record cannot be moved to another slot. Record keys (Signal addresses and +//! prekey IDs) stay readable so adapters can index and scan them. +//! +//! Sealing does not detect rollback: an adapter that serves an older sealed +//! value for the same slot passes authentication. + +mod memory; +mod record; +mod seal; +mod signal; + +#[cfg(any(test, feature = "conformance"))] +pub mod conformance; + +pub use memory::MemoryRecordStore; +pub use record::{ + Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, WriteOp, is_fence_lost, +}; +pub use seal::{AesGcmSealer, SealError, Sealer}; +pub use signal::RecordSignalStore; diff --git a/wacore/recordstore/src/memory.rs b/wacore/recordstore/src/memory.rs new file mode 100644 index 00000000..fbc42351 --- /dev/null +++ b/wacore/recordstore/src/memory.rs @@ -0,0 +1,191 @@ +use crate::record::{Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, WriteOp}; +use async_trait::async_trait; +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Mutex, MutexGuard}; +use wacore::store::error::{Result, StoreError}; + +type Records = BTreeMap<(Arc, Namespace, String), Vec>; + +#[derive(Default)] +struct State { + records: Records, + leases: HashMap, LeaseRow>, +} + +#[derive(Clone)] +struct LeaseRow { + holder: Arc, + generation: u64, + expires_at_ms: u64, +} + +/// In-process [`RecordStore`] and [`LeaseStore`]. The reference behaviour for +/// the conformance suite, and the fallback where no database is configured. +#[derive(Default)] +pub struct MemoryRecordStore { + state: Mutex, +} + +impl MemoryRecordStore { + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> Result> { + self.state + .lock() + .map_err(|_| StoreError::Validation("memory record store lock poisoned".into())) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl RecordStore for MemoryRecordStore { + async fn get(&self, scope: &str, ns: Namespace, key: &str) -> Result>> { + let state = self.lock()?; + Ok(state + .records + .get(&(Arc::from(scope), ns, key.to_owned())) + .cloned()) + } + + async fn scan_keys( + &self, + scope: &str, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> Result> { + let state = self.lock()?; + let scope: Arc = Arc::from(scope); + let start = (scope.clone(), ns, prefix.to_owned()); + let keys = state + .records + .range(start..) + .map(|((s, n, k), _)| (s, n, k)) + .take_while(|(s, n, k)| **s == scope && **n == ns && k.starts_with(prefix)) + .map(|(_, _, k)| k.clone()); + Ok(match limit { + Some(limit) => keys.take(limit).collect(), + None => keys.collect(), + }) + } + + async fn write(&self, fence: &Fence, ops: &[WriteOp]) -> Result<()> { + let mut state = self.lock()?; + let current = state.leases.get(&fence.scope).map(|row| row.generation); + if current != Some(fence.generation) { + return Err(FenceLost { + scope: fence.scope.clone(), + generation: fence.generation, + } + .into_store_error()); + } + // Validation happens before any mutation, so the batch stays atomic. + for op in ops { + match op { + WriteOp::Put { ns, key, value } => { + state + .records + .insert((fence.scope.clone(), *ns, key.clone()), value.clone()); + } + WriteOp::Update { ns, key, value } => { + if let Some(slot) = + state + .records + .get_mut(&(fence.scope.clone(), *ns, key.clone())) + { + slot.clone_from(value); + } + } + WriteOp::Delete { ns, key } => { + state + .records + .remove(&(fence.scope.clone(), *ns, key.clone())); + } + } + } + Ok(()) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl LeaseStore for MemoryRecordStore { + async fn acquire( + &self, + scope: &str, + holder: &str, + now_ms: u64, + ttl_ms: u64, + ) -> Result> { + // An empty holder is how a released lease is marked. + if holder.is_empty() { + return Err(StoreError::Validation( + "lease holder must not be empty".into(), + )); + } + let mut state = self.lock()?; + let scope: Arc = Arc::from(scope); + let expires_at_ms = now_ms.saturating_add(ttl_ms); + let generation = match state.leases.get(&scope) { + Some(row) if &*row.holder == holder => row.generation, + Some(row) if row.expires_at_ms > now_ms => return Ok(None), + Some(row) => row.generation + 1, + None => 1, + }; + let holder: Arc = Arc::from(holder); + state.leases.insert( + scope.clone(), + LeaseRow { + holder: holder.clone(), + generation, + expires_at_ms, + }, + ); + Ok(Some(Lease { + fence: Fence { scope, generation }, + holder, + expires_at_ms, + })) + } + + async fn renew(&self, lease: &Lease, now_ms: u64, ttl_ms: u64) -> Result> { + let mut state = self.lock()?; + let Some(row) = state.leases.get_mut(&lease.fence.scope) else { + return Ok(None); + }; + if row.generation != lease.fence.generation || row.holder != lease.holder { + return Ok(None); + } + row.expires_at_ms = now_ms.saturating_add(ttl_ms); + Ok(Some(Lease { + expires_at_ms: row.expires_at_ms, + ..lease.clone() + })) + } + + async fn release(&self, lease: &Lease) -> Result<()> { + let mut state = self.lock()?; + if let Some(row) = state.leases.get_mut(&lease.fence.scope) + && row.generation == lease.fence.generation + && row.holder == lease.holder + { + // Expire it but keep the generation: the next holder must still + // advance past it, or a delayed write from this lease could land. + row.expires_at_ms = 0; + row.holder = Arc::from(""); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn passes_the_conformance_suite() { + futures::executor::block_on(crate::conformance::run_all(&MemoryRecordStore::new())); + } +} diff --git a/wacore/recordstore/src/record.rs b/wacore/recordstore/src/record.rs new file mode 100644 index 00000000..3080cb20 --- /dev/null +++ b/wacore/recordstore/src/record.rs @@ -0,0 +1,158 @@ +use async_trait::async_trait; +use std::sync::Arc; +use wacore::store::error::{Result, StoreError}; + +/// Record families. The wire tag is persisted by adapters; never renumber. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Namespace { + Identity, + Session, + PreKey, + SignedPreKey, + SenderKey, +} + +impl Namespace { + pub const ALL: [Namespace; 5] = [ + Namespace::Identity, + Namespace::Session, + Namespace::PreKey, + Namespace::SignedPreKey, + Namespace::SenderKey, + ]; + + /// Stable persisted name. + pub const fn tag(self) -> &'static str { + match self { + Namespace::Identity => "identity", + Namespace::Session => "session", + Namespace::PreKey => "prekey", + Namespace::SignedPreKey => "signed_prekey", + Namespace::SenderKey => "sender_key", + } + } +} + +/// Authority to write one scope. A scope is one linked device's Signal state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fence { + pub scope: Arc, + pub generation: u64, +} + +/// A held lease. `fence` stays constant across renewals by the same holder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Lease { + pub fence: Fence, + pub holder: Arc, + pub expires_at_ms: u64, +} + +/// One change inside an atomic [`RecordStore::write`] batch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WriteOp { + /// Insert or replace. + Put { + ns: Namespace, + key: String, + value: Vec, + }, + /// Replace only if the key exists; a missing key is a no-op, never an + /// insert. Used where resurrecting a deleted record would be a bug. + Update { + ns: Namespace, + key: String, + value: Vec, + }, + /// Remove; a missing key is a no-op. + Delete { ns: Namespace, key: String }, +} + +/// The write was refused because `fence` no longer matches the scope's lease. +#[derive(Debug, Clone, thiserror::Error)] +#[error("lease for scope {scope} moved past generation {generation}")] +pub struct FenceLost { + pub scope: Arc, + pub generation: u64, +} + +impl FenceLost { + pub fn into_store_error(self) -> StoreError { + StoreError::Database(Box::new(self)) + } +} + +/// Whether `error` (or any error it wraps) is a [`FenceLost`]. A node that +/// sees this must stop serving the scope; retrying cannot succeed. +pub fn is_fence_lost(error: &StoreError) -> bool { + let mut layer: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = layer { + if current.is::() { + return true; + } + layer = current.source(); + } + false +} + +/// Byte-level storage an adapter implements. Values are already sealed. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait RecordStore: Send + Sync { + async fn get(&self, scope: &str, ns: Namespace, key: &str) -> Result>>; + + /// Returns only keys that exist, in any order. + async fn get_many( + &self, + scope: &str, + ns: Namespace, + keys: &[&str], + ) -> Result)>> { + let mut found = Vec::with_capacity(keys.len()); + for key in keys { + if let Some(value) = self.get(scope, ns, key).await? { + found.push(((*key).to_owned(), value)); + } + } + Ok(found) + } + + /// Keys starting with `prefix`, ascending by byte order, at most `limit`. + async fn scan_keys( + &self, + scope: &str, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> Result>; + + /// Apply every op or none, and only while `fence.generation` is the + /// scope's current lease generation. Otherwise return + /// [`FenceLost::into_store_error`] and apply nothing. + async fn write(&self, fence: &Fence, ops: &[WriteOp]) -> Result<()>; +} + +/// Per-scope single-writer leases. Time is supplied by the caller so the +/// contract holds on targets without a system clock. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait LeaseStore: Send + Sync { + /// Take the scope if it is free, expired, or already held by `holder`. + /// A new holder gets a strictly higher generation; the same holder keeps + /// its generation. Returns `None` while another holder's lease is live. + async fn acquire( + &self, + scope: &str, + holder: &str, + now_ms: u64, + ttl_ms: u64, + ) -> Result>; + + /// Extend `lease`. Returns `None` if the scope moved to another + /// generation, in which case the caller lost it. + async fn renew(&self, lease: &Lease, now_ms: u64, ttl_ms: u64) -> Result>; + + /// Give the scope up early. Releasing a lease that was already lost is a + /// no-op. The generation is kept so a later holder still advances it. + async fn release(&self, lease: &Lease) -> Result<()>; +} diff --git a/wacore/recordstore/src/seal.rs b/wacore/recordstore/src/seal.rs new file mode 100644 index 00000000..86866e65 --- /dev/null +++ b/wacore/recordstore/src/seal.rs @@ -0,0 +1,157 @@ +use aes_gcm::{ + Aes256Gcm, KeyInit, + aead::{Aead, Payload}, +}; +use async_trait::async_trait; +use rand::Rng; + +/// Seals record values before they reach a [`crate::RecordStore`]. +/// +/// Implementations other than [`AesGcmSealer`] exist where the key must not +/// enter this process, for example a non-extractable WebCrypto key in a +/// browser. `aad` must be authenticated, not encrypted. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait Sealer: Send + Sync { + async fn seal(&self, aad: &[u8], plaintext: &[u8]) -> Result, SealError>; + async fn open(&self, aad: &[u8], sealed: &[u8]) -> Result, SealError>; +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum SealError { + /// Unknown format version: written by a newer release, or not sealed. + #[error("unsupported sealed record format {0}")] + UnsupportedFormat(u8), + #[error("sealed record is truncated")] + Truncated, + /// Wrong key, tampered bytes, or a record moved to another slot. + #[error("sealed record failed authentication")] + Authentication, + #[error("sealer backend failed: {0}")] + Backend(String), +} + +/// AES-256-GCM with a 256-bit data key held in memory. +/// +/// Layout, format 1: `[0x01][12-byte random nonce][ciphertext || 16-byte tag]`. +/// Random 96-bit nonces stay within GCM's safety margin for well over 2^32 +/// records per key; rotate the data key before that. +pub struct AesGcmSealer { + cipher: Aes256Gcm, +} + +const FORMAT_V1: u8 = 1; +const NONCE_LEN: usize = 12; +const TAG_LEN: usize = 16; + +impl AesGcmSealer { + pub fn new(data_key: &[u8; 32]) -> Self { + // A 32-byte slice always matches Aes256Gcm's key size. + let cipher = Aes256Gcm::new(data_key.into()); + Self { cipher } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl Sealer for AesGcmSealer { + async fn seal(&self, aad: &[u8], plaintext: &[u8]) -> Result, SealError> { + let mut nonce = [0u8; NONCE_LEN]; + rand::rng().fill_bytes(&mut nonce); + let ciphertext = self + .cipher + .encrypt( + &nonce.into(), + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|_| SealError::Backend("aes-gcm encryption failed".into()))?; + let mut out = Vec::with_capacity(1 + NONCE_LEN + ciphertext.len()); + out.push(FORMAT_V1); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + Ok(out) + } + + async fn open(&self, aad: &[u8], sealed: &[u8]) -> Result, SealError> { + let (&format, rest) = sealed.split_first().ok_or(SealError::Truncated)?; + if format != FORMAT_V1 { + return Err(SealError::UnsupportedFormat(format)); + } + if rest.len() < NONCE_LEN + TAG_LEN { + return Err(SealError::Truncated); + } + let (nonce, ciphertext) = rest.split_at(NONCE_LEN); + let nonce: [u8; NONCE_LEN] = nonce.try_into().map_err(|_| SealError::Truncated)?; + self.cipher + .decrypt( + &nonce.into(), + Payload { + msg: ciphertext, + aad, + }, + ) + .map_err(|_| SealError::Authentication) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::executor::block_on; + + fn sealer(byte: u8) -> AesGcmSealer { + AesGcmSealer::new(&[byte; 32]) + } + + #[test] + fn round_trips_and_uses_fresh_nonces() { + let s = sealer(7); + let a = block_on(s.seal(b"slot", b"value")).unwrap(); + let b = block_on(s.seal(b"slot", b"value")).unwrap(); + assert_ne!(a, b, "two seals of the same value must differ"); + assert_eq!(block_on(s.open(b"slot", &a)).unwrap(), b"value"); + } + + #[test] + fn a_record_moved_to_another_slot_fails() { + let s = sealer(7); + let sealed = block_on(s.seal(b"slot-a", b"value")).unwrap(); + assert_eq!( + block_on(s.open(b"slot-b", &sealed)), + Err(SealError::Authentication) + ); + } + + #[test] + fn a_different_key_fails() { + let sealed = block_on(sealer(7).seal(b"slot", b"value")).unwrap(); + assert_eq!( + block_on(sealer(8).open(b"slot", &sealed)), + Err(SealError::Authentication) + ); + } + + #[test] + fn tampering_and_truncation_are_rejected() { + let s = sealer(7); + let mut sealed = block_on(s.seal(b"slot", b"value")).unwrap(); + let last = sealed.len() - 1; + sealed[last] ^= 1; + assert_eq!( + block_on(s.open(b"slot", &sealed)), + Err(SealError::Authentication) + ); + assert_eq!(block_on(s.open(b"slot", &[])), Err(SealError::Truncated)); + assert_eq!( + block_on(s.open(b"slot", &[FORMAT_V1, 0, 0])), + Err(SealError::Truncated) + ); + assert_eq!( + block_on(s.open(b"slot", &[9; 40])), + Err(SealError::UnsupportedFormat(9)) + ); + } +} diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs new file mode 100644 index 00000000..33985c88 --- /dev/null +++ b/wacore/recordstore/src/signal.rs @@ -0,0 +1,566 @@ +use crate::record::{Fence, Namespace, RecordStore, WriteOp}; +use crate::seal::{SealError, Sealer}; +use async_trait::async_trait; +use bytes::Bytes; +use std::sync::Arc; +use wacore::store::error::{Result, StoreError}; +use wacore::store::traits::SignalStore; + +const AAD_DOMAIN: &[u8] = b"wacore-recordstore/v1"; + +/// Prekey plaintext starts with this flag byte, then the libsignal record. +const PREKEY_NOT_UPLOADED: u8 = 0; +const PREKEY_UPLOADED: u8 = 1; + +/// [`SignalStore`] for one scope, sealed by `S` and fenced by the lease that +/// produced `fence`. Build a new one whenever the lease is re-acquired. +pub struct RecordSignalStore { + records: Arc, + sealer: Arc, + fence: Fence, +} + +impl RecordSignalStore { + pub fn new(records: Arc, sealer: Arc, fence: Fence) -> Self { + Self { + records, + sealer, + fence, + } + } + + pub fn fence(&self) -> &Fence { + &self.fence + } + + fn scope(&self) -> &str { + &self.fence.scope + } + + fn aad(&self, ns: Namespace, key: &str) -> Vec { + let scope = self.scope().as_bytes(); + let tag = ns.tag().as_bytes(); + let mut aad = + Vec::with_capacity(AAD_DOMAIN.len() + scope.len() + tag.len() + key.len() + 3); + aad.extend_from_slice(AAD_DOMAIN); + aad.push(0); + aad.extend_from_slice(scope); + aad.push(0); + aad.extend_from_slice(tag); + aad.push(0); + aad.extend_from_slice(key.as_bytes()); + aad + } + + async fn seal(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result> { + self.sealer + .seal(&self.aad(ns, key), plaintext) + .await + .map_err(seal_error) + } + + async fn open(&self, ns: Namespace, key: &str, sealed: &[u8]) -> Result> { + self.sealer + .open(&self.aad(ns, key), sealed) + .await + .map_err(seal_error) + } + + async fn load(&self, ns: Namespace, key: &str) -> Result>> { + match self.records.get(self.scope(), ns, key).await? { + Some(sealed) => Ok(Some(self.open(ns, key, &sealed).await?)), + None => Ok(None), + } + } + + async fn load_many(&self, ns: Namespace, keys: &[&str]) -> Result)>> { + let found = self.records.get_many(self.scope(), ns, keys).await?; + let mut opened = Vec::with_capacity(found.len()); + for (key, sealed) in found { + let plaintext = self.open(ns, &key, &sealed).await?; + opened.push((key, plaintext)); + } + Ok(opened) + } + + async fn put_op(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result { + Ok(WriteOp::Put { + ns, + key: key.to_owned(), + value: self.seal(ns, key, plaintext).await?, + }) + } + + async fn write(&self, ops: &[WriteOp]) -> Result<()> { + if ops.is_empty() { + return Ok(()); + } + self.records.write(&self.fence, ops).await + } + + async fn put_one(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result<()> { + let op = self.put_op(ns, key, plaintext).await?; + self.write(std::slice::from_ref(&op)).await + } + + async fn delete_keys>(&self, ns: Namespace, keys: &[K]) -> Result<()> { + let ops: Vec<_> = keys + .iter() + .map(|key| WriteOp::Delete { + ns, + key: key.as_ref().to_owned(), + }) + .collect(); + self.write(&ops).await + } + + async fn any_key_with_prefix(&self, ns: Namespace, prefix: &str) -> Result { + Ok(!self + .records + .scan_keys(self.scope(), ns, prefix, Some(1)) + .await? + .is_empty()) + } +} + +fn seal_error(error: SealError) -> StoreError { + StoreError::Serialization(Box::new(error)) +} + +/// Zero-padded so byte order matches numeric order in every adapter. +fn id_key(id: u32) -> String { + format!("{id:010}") +} + +fn parse_id(key: &str) -> Result { + key.parse() + .map_err(|_| StoreError::Validation(format!("invalid record id key: {key}"))) +} + +fn prekey_plaintext(record: &[u8], uploaded: bool) -> Vec { + let mut plaintext = Vec::with_capacity(record.len() + 1); + plaintext.push(if uploaded { + PREKEY_UPLOADED + } else { + PREKEY_NOT_UPLOADED + }); + plaintext.extend_from_slice(record); + plaintext +} + +fn prekey_record(plaintext: Vec) -> Result { + match plaintext.first() { + Some(&PREKEY_NOT_UPLOADED | &PREKEY_UPLOADED) => Ok(Bytes::from(plaintext).slice(1..)), + _ => Err(StoreError::Validation( + "prekey record has no upload flag".into(), + )), + } +} + +fn identity_key(plaintext: Vec) -> Result<[u8; 32]> { + let len = plaintext.len(); + plaintext + .try_into() + .map_err(|_| StoreError::Validation(format!("Invalid identity key length: {len}"))) +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl SignalStore for RecordSignalStore { + async fn put_identity(&self, address: &str, key: [u8; 32]) -> Result<()> { + self.put_one(Namespace::Identity, address, &key).await + } + + async fn put_identities_batch(&self, identities: &[(Arc, [u8; 32])]) -> Result<()> { + let mut ops = Vec::with_capacity(identities.len()); + for (address, key) in identities { + ops.push(self.put_op(Namespace::Identity, address, key).await?); + } + self.write(&ops).await + } + + async fn load_identity(&self, address: &str) -> Result> { + self.load(Namespace::Identity, address) + .await? + .map(identity_key) + .transpose() + } + + async fn delete_identity(&self, address: &str) -> Result<()> { + self.delete_keys(Namespace::Identity, &[address]).await + } + + async fn delete_identities_batch(&self, addresses: &[Arc]) -> Result<()> { + self.delete_keys(Namespace::Identity, addresses).await + } + + async fn get_session(&self, address: &str) -> Result> { + Ok(self + .load(Namespace::Session, address) + .await? + .map(Bytes::from)) + } + + async fn put_session(&self, address: &str, session: &[u8]) -> Result<()> { + self.put_one(Namespace::Session, address, session).await + } + + async fn put_sessions_batch(&self, sessions: &[(Arc, Bytes)]) -> Result<()> { + let mut ops = Vec::with_capacity(sessions.len()); + for (address, session) in sessions { + ops.push(self.put_op(Namespace::Session, address, session).await?); + } + self.write(&ops).await + } + + async fn get_sessions_batch(&self, addresses: &[Arc]) -> Result, Bytes)>> { + let keys: Vec<&str> = addresses.iter().map(|a| &**a).collect(); + let found = self.load_many(Namespace::Session, &keys).await?; + Ok(found + .into_iter() + .map(|(key, value)| { + // Hand back the caller's own Arc where possible. + let address = addresses + .iter() + .find(|a| ***a == *key) + .cloned() + .unwrap_or_else(|| Arc::from(key)); + (address, Bytes::from(value)) + }) + .collect()) + } + + async fn delete_session(&self, address: &str) -> Result<()> { + self.delete_keys(Namespace::Session, &[address]).await + } + + async fn delete_sessions_batch(&self, addresses: &[Arc]) -> Result<()> { + self.delete_keys(Namespace::Session, addresses).await + } + + async fn has_session(&self, address: &str) -> Result { + Ok(self + .records + .get(self.scope(), Namespace::Session, address) + .await? + .is_some()) + } + + async fn has_signal_state_for_user(&self, user: &str) -> Result { + // Addresses are `user@server` (device 0) or `user:device@server`. + let device_zero = format!("{user}@"); + let other_devices = format!("{user}:"); + for ns in [Namespace::Session, Namespace::Identity] { + if self.any_key_with_prefix(ns, &device_zero).await? + || self.any_key_with_prefix(ns, &other_devices).await? + { + return Ok(true); + } + } + Ok(false) + } + + async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()> { + let key = id_key(id); + self.put_one(Namespace::PreKey, &key, &prekey_plaintext(record, uploaded)) + .await + } + + async fn store_prekeys_batch(&self, keys: &[(u32, Bytes)], uploaded: bool) -> Result<()> { + let mut ops = Vec::with_capacity(keys.len()); + for (id, record) in keys { + let key = id_key(*id); + ops.push( + self.put_op(Namespace::PreKey, &key, &prekey_plaintext(record, uploaded)) + .await?, + ); + } + self.write(&ops).await + } + + async fn load_prekey(&self, id: u32) -> Result> { + self.load(Namespace::PreKey, &id_key(id)) + .await? + .map(prekey_record) + .transpose() + } + + async fn load_prekeys_batch(&self, ids: &[u32]) -> Result> { + let keys: Vec = ids.iter().map(|id| id_key(*id)).collect(); + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let mut result = Vec::with_capacity(ids.len()); + for (key, plaintext) in self.load_many(Namespace::PreKey, &key_refs).await? { + result.push((parse_id(&key)?, prekey_record(plaintext)?)); + } + Ok(result) + } + + async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<()> { + let keys: Vec = ids.iter().map(|id| id_key(*id)).collect(); + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let mut ops = Vec::with_capacity(ids.len()); + for (key, plaintext) in self.load_many(Namespace::PreKey, &key_refs).await? { + let record = prekey_record(plaintext)?; + let value = self + .seal(Namespace::PreKey, &key, &prekey_plaintext(&record, true)) + .await?; + // Update, not Put: a key consumed after the read above stays + // deleted instead of being resurrected by this write. + ops.push(WriteOp::Update { + ns: Namespace::PreKey, + key, + value, + }); + } + self.write(&ops).await + } + + async fn remove_prekey(&self, id: u32) -> Result<()> { + self.delete_keys(Namespace::PreKey, &[id_key(id)]).await + } + + async fn remove_prekeys_batch(&self, ids: &[u32]) -> Result<()> { + let keys: Vec = ids.iter().map(|id| id_key(*id)).collect(); + self.delete_keys(Namespace::PreKey, &keys).await + } + + async fn get_max_prekey_id(&self) -> Result { + let keys = self + .records + .scan_keys(self.scope(), Namespace::PreKey, "", None) + .await?; + keys.last().map_or(Ok(0), |key| parse_id(key)) + } + + async fn store_signed_prekey(&self, id: u32, record: &[u8]) -> Result<()> { + self.put_one(Namespace::SignedPreKey, &id_key(id), record) + .await + } + + async fn load_signed_prekey(&self, id: u32) -> Result>> { + self.load(Namespace::SignedPreKey, &id_key(id)).await + } + + async fn load_all_signed_prekeys(&self) -> Result)>> { + let keys = self + .records + .scan_keys(self.scope(), Namespace::SignedPreKey, "", None) + .await?; + let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + let mut result = Vec::with_capacity(keys.len()); + for (key, plaintext) in self.load_many(Namespace::SignedPreKey, &key_refs).await? { + result.push((parse_id(&key)?, plaintext)); + } + result.sort_by_key(|(id, _)| *id); + Ok(result) + } + + async fn remove_signed_prekey(&self, id: u32) -> Result<()> { + self.delete_keys(Namespace::SignedPreKey, &[id_key(id)]) + .await + } + + async fn put_sender_key(&self, address: &str, record: &[u8]) -> Result<()> { + self.put_one(Namespace::SenderKey, address, record).await + } + + async fn put_sender_keys_batch(&self, sender_keys: &[(Arc, Bytes)]) -> Result<()> { + let mut ops = Vec::with_capacity(sender_keys.len()); + for (address, record) in sender_keys { + ops.push(self.put_op(Namespace::SenderKey, address, record).await?); + } + self.write(&ops).await + } + + async fn get_sender_key(&self, address: &str) -> Result>> { + self.load(Namespace::SenderKey, address).await + } + + async fn delete_sender_key(&self, address: &str) -> Result<()> { + self.delete_keys(Namespace::SenderKey, &[address]).await + } + + async fn delete_sender_keys_batch(&self, addresses: &[Arc]) -> Result<()> { + self.delete_keys(Namespace::SenderKey, addresses).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::memory::MemoryRecordStore; + use crate::record::{LeaseStore, is_fence_lost}; + use crate::seal::AesGcmSealer; + use futures::executor::block_on; + + const TTL: u64 = 10_000; + + fn setup() -> ( + Arc, + RecordSignalStore, + ) { + let records = Arc::new(MemoryRecordStore::new()); + let lease = block_on(records.acquire("device-1", "node-a", 0, TTL)) + .unwrap() + .unwrap(); + let store = RecordSignalStore::new( + records.clone(), + Arc::new(AesGcmSealer::new(&[3; 32])), + lease.fence, + ); + (records, store) + } + + #[test] + fn values_are_sealed_at_rest() { + let (records, store) = setup(); + block_on(store.put_session("alice@s.whatsapp.net", b"ratchet-state")).unwrap(); + let raw = block_on(records.get("device-1", Namespace::Session, "alice@s.whatsapp.net")) + .unwrap() + .unwrap(); + assert!( + !raw.windows(b"ratchet-state".len()) + .any(|w| w == b"ratchet-state"), + "plaintext reached the record store" + ); + assert_eq!( + block_on(store.get_session("alice@s.whatsapp.net")) + .unwrap() + .as_deref(), + Some(&b"ratchet-state"[..]) + ); + } + + #[test] + fn a_record_copied_to_another_address_fails_to_open() { + let (records, store) = setup(); + block_on(store.put_session("alice@s.whatsapp.net", b"alice-state")).unwrap(); + let raw = block_on(records.get("device-1", Namespace::Session, "alice@s.whatsapp.net")) + .unwrap() + .unwrap(); + block_on(records.write( + store.fence(), + &[WriteOp::Put { + ns: Namespace::Session, + key: "mallory@s.whatsapp.net".into(), + value: raw, + }], + )) + .unwrap(); + assert!(block_on(store.get_session("mallory@s.whatsapp.net")).is_err()); + } + + #[test] + fn a_superseded_node_cannot_write() { + let (records, stale) = setup(); + let takeover = block_on(records.acquire("device-1", "node-b", TTL, TTL)) + .unwrap() + .unwrap(); + let fresh = RecordSignalStore::new( + records.clone(), + Arc::new(AesGcmSealer::new(&[3; 32])), + takeover.fence, + ); + let err = block_on(stale.put_session("alice@s.whatsapp.net", b"old")).unwrap_err(); + assert!(is_fence_lost(&err)); + block_on(fresh.put_session("alice@s.whatsapp.net", b"new")).unwrap(); + assert_eq!( + block_on(fresh.get_session("alice@s.whatsapp.net")) + .unwrap() + .as_deref(), + Some(&b"new"[..]) + ); + } + + #[test] + fn identities_round_trip_and_reject_bad_lengths() { + let (_, store) = setup(); + block_on(store.put_identity("bob@s.whatsapp.net", [9; 32])).unwrap(); + assert_eq!( + block_on(store.load_identity("bob@s.whatsapp.net")).unwrap(), + Some([9; 32]) + ); + assert_eq!( + block_on(store.load_identity("nobody@s.whatsapp.net")).unwrap(), + None + ); + } + + #[test] + fn prekeys_keep_numeric_order_and_never_resurrect() { + let (_, store) = setup(); + let keys = vec![ + (9, Bytes::from_static(b"nine")), + (10, Bytes::from_static(b"ten")), + (100, Bytes::from_static(b"hundred")), + ]; + block_on(store.store_prekeys_batch(&keys, false)).unwrap(); + assert_eq!(block_on(store.get_max_prekey_id()).unwrap(), 100); + + block_on(store.remove_prekey(10)).unwrap(); + block_on(store.mark_prekeys_uploaded(&[9, 10])).unwrap(); + assert_eq!(block_on(store.load_prekey(10)).unwrap(), None); + assert_eq!( + block_on(store.load_prekey(9)).unwrap().as_deref(), + Some(&b"nine"[..]) + ); + + let mut loaded = block_on(store.load_prekeys_batch(&[9, 10, 100])).unwrap(); + loaded.sort_by_key(|(id, _)| *id); + assert_eq!( + loaded, + vec![ + (9, Bytes::from_static(b"nine")), + (100, Bytes::from_static(b"hundred")) + ] + ); + } + + #[test] + fn signed_prekeys_list_in_order() { + let (_, store) = setup(); + block_on(store.store_signed_prekey(12, b"twelve")).unwrap(); + block_on(store.store_signed_prekey(2, b"two")).unwrap(); + assert_eq!( + block_on(store.load_all_signed_prekeys()).unwrap(), + vec![(2, b"two".to_vec()), (12, b"twelve".to_vec())] + ); + block_on(store.remove_signed_prekey(2)).unwrap(); + assert_eq!(block_on(store.load_signed_prekey(2)).unwrap(), None); + } + + #[test] + fn signal_state_lookup_matches_whole_user_ids() { + let (_, store) = setup(); + block_on(store.put_session("123:4@s.whatsapp.net", b"s")).unwrap(); + assert!(block_on(store.has_signal_state_for_user("123")).unwrap()); + assert!(!block_on(store.has_signal_state_for_user("12")).unwrap()); + assert!(!block_on(store.has_signal_state_for_user("1234")).unwrap()); + } + + #[test] + fn batches_and_sender_keys_round_trip() { + let (_, store) = setup(); + let a: Arc = Arc::from("a@s.whatsapp.net"); + let b: Arc = Arc::from("b@s.whatsapp.net"); + block_on(store.put_sessions_batch(&[ + (a.clone(), Bytes::from_static(b"sa")), + (b.clone(), Bytes::from_static(b"sb")), + ])) + .unwrap(); + let mut found = block_on(store.get_sessions_batch(&[a.clone(), b.clone()])).unwrap(); + found.sort_by(|x, y| x.0.cmp(&y.0)); + assert_eq!(found.len(), 2); + block_on(store.delete_sessions_batch(std::slice::from_ref(&a))).unwrap(); + assert!(!block_on(store.has_session(&a)).unwrap()); + assert!(block_on(store.has_session(&b)).unwrap()); + + block_on(store.put_sender_key("group:me", b"sk")).unwrap(); + assert_eq!( + block_on(store.get_sender_key("group:me")).unwrap(), + Some(b"sk".to_vec()) + ); + block_on(store.delete_sender_key("group:me")).unwrap(); + assert_eq!(block_on(store.get_sender_key("group:me")).unwrap(), None); + } +} From 39320aefe4693de6092d4ed11cb78f734f7ffa09 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 8 Oct 2026 06:39:14 +0300 Subject: [PATCH 02/17] refactor(pair): pair crypto from the identity key and ADV secret alone do_pair_crypto only uses the identity key and the ADV secret, but takes a DeviceState that also carries the noise key pair. Add do_pair_crypto_with, which takes just the two secrets it uses, and delegate to it. A Signal store that holds the identity key but never the noise key can now complete pairing. Behaviour is unchanged. --- wacore/src/pair.rs | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index fe9f594e..9b157375 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -173,6 +173,21 @@ impl PairUtils { pub fn do_pair_crypto( device_state: &DeviceState, device_identity_bytes: &[u8], + ) -> Result<(Vec, u32), PairCryptoError> { + Self::do_pair_crypto_with( + &device_state.identity_key, + &device_state.adv_secret_key, + device_identity_bytes, + ) + } + + /// [`Self::do_pair_crypto`] with only the two secrets it uses, so a + /// process that holds the identity key but never the noise key (an + /// external Signal store) can complete pairing. + pub fn do_pair_crypto_with( + identity_key: &KeyPair, + adv_secret_key: &[u8; 32], + device_identity_bytes: &[u8], ) -> Result<(Vec, u32), PairCryptoError> { let hmac_container = waproto::codec::adv_signed_device_identity_hmac_decode( device_identity_bytes, @@ -185,12 +200,14 @@ impl PairUtils { let is_hosted_account = hmac_container.account_type == Some(ADVEncryptionType::HOSTED); - let mut mac = ::new_from_slice(&device_state.adv_secret_key) - .map_err(|e| PairCryptoError { - code: 500, - text: "internal-error", - source: e.into(), - })?; + let mut mac = + ::new_from_slice(adv_secret_key).map_err(|e| { + PairCryptoError { + code: 500, + text: "internal-error", + source: e.into(), + } + })?; let details_bytes = hmac_container .details .as_deref() @@ -255,7 +272,7 @@ impl PairUtils { let msg_to_verify = AccountSignatureMessage::new( inner_details_bytes, - &device_state.identity_key.public_key, + &identity_key.public_key, identity_details.device_type, ); @@ -283,8 +300,7 @@ impl PairUtils { // WAWebAdvSignatureApi.generateDeviceSignature always signs a regular companion. let msg_to_sign = msg_to_verify.for_device(&account_public_key, DeviceSignatureKind::Companion); - let device_signature = device_state - .identity_key + let device_signature = identity_key .private_key .calculate_signature(&msg_to_sign, &mut rand::make_rng::()) .map_err(|e| PairCryptoError { From e25c07aa165c6c12b2dd931dc3bd4e3f815ee49a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 8 Oct 2026 06:39:14 +0300 Subject: [PATCH 03/17] feat(signal-ops): Signal operations for externally held device keys Add wacore-signal-ops: the operations a WhatsApp client needs from a Signal store it does not hold the private keys for. Decrypt pkmsg/msg, encrypt, establish a session from a fetched bundle, sender-key distribution and group encrypt/decrypt, one-time prekey generation, signed prekey rotation, and the pairing device signature. The pairing operation verifies the primary's container and signs only the ADV device-identity message it builds itself, never caller-chosen bytes. A consumed one-time prekey is deleted only after the promoted session is written. Records use wacore's for-store encodings with a per-process incarnation, so a failover reload skips past possibly used chain positions. wacore-recordstore gains a sealed Device namespace for the local key blob. End-to-end tests run two devices over sealed, fenced record stores: both directions, group sender keys, signed prekey rotation, pairing, and a node failover in which the superseded node is fenced out. --- .github/workflows/wasm.yml | 7 + Cargo.lock | 18 ++ Cargo.toml | 2 + wacore/recordstore/src/record.rs | 7 +- wacore/recordstore/src/signal.rs | 26 ++ wacore/signal-ops/Cargo.toml | 27 ++ wacore/signal-ops/src/device.rs | 201 ++++++++++++ wacore/signal-ops/src/error.rs | 34 ++ wacore/signal-ops/src/lib.rs | 23 ++ wacore/signal-ops/src/ops.rs | 433 ++++++++++++++++++++++++++ wacore/signal-ops/src/stores.rs | 288 +++++++++++++++++ wacore/signal-ops/tests/end_to_end.rs | 317 +++++++++++++++++++ 12 files changed, 1382 insertions(+), 1 deletion(-) create mode 100644 wacore/signal-ops/Cargo.toml create mode 100644 wacore/signal-ops/src/device.rs create mode 100644 wacore/signal-ops/src/error.rs create mode 100644 wacore/signal-ops/src/lib.rs create mode 100644 wacore/signal-ops/src/ops.rs create mode 100644 wacore/signal-ops/src/stores.rs create mode 100644 wacore/signal-ops/tests/end_to_end.rs diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index c3dc54ae..ba238456 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -95,6 +95,13 @@ jobs: cargo build -p wacore-recordstore --lib --release --target wasm32-unknown-unknown --features wacore/js + - name: Build wacore-signal-ops for wasm32 (release) + env: + RUSTFLAGS: '--cfg getrandom_backend="wasm_js"' + run: > + cargo build -p wacore-signal-ops --lib --release + --target wasm32-unknown-unknown + --features wacore/js - name: Guard sqlite-storage's platform shim run: cargo xt ci workflow guard-sqlite-shim # The sans-IO VoIP core (wacore::voip: the CallEngine, the portable run_call driver, diff --git a/Cargo.lock b/Cargo.lock index 601eb2d9..3a6b796a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5336,6 +5336,24 @@ dependencies = [ "wacore", ] +[[package]] +name = "wacore-signal-ops" +version = "0.1.0" +dependencies = [ + "async-lock", + "async-trait", + "buffa", + "bytes", + "futures", + "hmac 0.13.0", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror 2.0.21", + "wacore", + "wacore-recordstore", + "waproto", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index c33e8196..393f13cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,7 @@ members = [ "wacore/libsignal", "wacore/noise", "wacore/recordstore", + "wacore/signal-ops", "waproto", ] default-members = [ @@ -57,6 +58,7 @@ default-members = [ "wacore/libsignal", "wacore/noise", "wacore/recordstore", + "wacore/signal-ops", "waproto", ] diff --git a/wacore/recordstore/src/record.rs b/wacore/recordstore/src/record.rs index 3080cb20..741a700c 100644 --- a/wacore/recordstore/src/record.rs +++ b/wacore/recordstore/src/record.rs @@ -10,15 +10,19 @@ pub enum Namespace { PreKey, SignedPreKey, SenderKey, + /// The local device's own keys (identity, registration ID, ADV secret, + /// current signed prekey). Read and written whole by the Signal service. + Device, } impl Namespace { - pub const ALL: [Namespace; 5] = [ + pub const ALL: [Namespace; 6] = [ Namespace::Identity, Namespace::Session, Namespace::PreKey, Namespace::SignedPreKey, Namespace::SenderKey, + Namespace::Device, ]; /// Stable persisted name. @@ -29,6 +33,7 @@ impl Namespace { Namespace::PreKey => "prekey", Namespace::SignedPreKey => "signed_prekey", Namespace::SenderKey => "sender_key", + Namespace::Device => "device", } } } diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 33985c88..04eddb14 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -7,6 +7,7 @@ use wacore::store::error::{Result, StoreError}; use wacore::store::traits::SignalStore; const AAD_DOMAIN: &[u8] = b"wacore-recordstore/v1"; +const DEVICE_KEY: &str = "local"; /// Prekey plaintext starts with this flag byte, then the libsignal record. const PREKEY_NOT_UPLOADED: u8 = 0; @@ -114,6 +115,16 @@ impl RecordSignalStore { self.write(&ops).await } + /// The local device's sealed key blob, if the scope has been initialised. + pub async fn load_device(&self) -> Result>> { + self.load(Namespace::Device, DEVICE_KEY).await + } + + /// Replace the local device's key blob, fenced like every other write. + pub async fn save_device(&self, blob: &[u8]) -> Result<()> { + self.put_one(Namespace::Device, DEVICE_KEY, blob).await + } + async fn any_key_with_prefix(&self, ns: Namespace, prefix: &str) -> Result { Ok(!self .records @@ -411,6 +422,21 @@ mod tests { (records, store) } + #[test] + fn device_blob_round_trips_sealed_and_fenced() { + let (records, store) = setup(); + assert_eq!(block_on(store.load_device()).unwrap(), None); + block_on(store.save_device(b"identity-private")).unwrap(); + assert_eq!( + block_on(store.load_device()).unwrap().as_deref(), + Some(&b"identity-private"[..]) + ); + let raw = block_on(records.get("device-1", Namespace::Device, "local")) + .unwrap() + .unwrap(); + assert!(!raw.windows(16).any(|w| w == b"identity-private")); + } + #[test] fn values_are_sealed_at_rest() { let (records, store) = setup(); diff --git a/wacore/signal-ops/Cargo.toml b/wacore/signal-ops/Cargo.toml new file mode 100644 index 00000000..8c78eff4 --- /dev/null +++ b/wacore/signal-ops/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "wacore-signal-ops" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +authors = ["Polymorfa"] +license = "MIT" +repository = "https://github.com/polymorfa/whatsapp-rust" +description = "Signal operations for a WhatsApp linked device whose keys live outside the client" +publish = false + +[dependencies] +async-lock = { workspace = true } +async-trait = { workspace = true } +bytes = { workspace = true } +rand = { workspace = true } +thiserror = { workspace = true } +wacore = { workspace = true } +wacore-recordstore = { path = "../recordstore" } +waproto = { workspace = true } + +[dev-dependencies] +buffa = { workspace = true } +futures = { workspace = true, features = ["executor"] } +hmac = { workspace = true } +sha2 = { workspace = true } +wacore = { workspace = true, features = ["test-util"] } diff --git a/wacore/signal-ops/src/device.rs b/wacore/signal-ops/src/device.rs new file mode 100644 index 00000000..a36b5176 --- /dev/null +++ b/wacore/signal-ops/src/device.rs @@ -0,0 +1,201 @@ +use crate::error::OpsError; +use rand::{Rng, RngExt}; +use wacore::libsignal::protocol::{IdentityKeyPair, KeyPair, PrivateKey, PublicKey}; + +/// The local device's own keys. Private halves never leave the Signal store. +#[derive(Clone)] +pub struct DeviceKeys { + pub identity: KeyPair, + pub registration_id: u32, + pub adv_secret: [u8; 32], + pub signed_prekey_id: u32, + pub signed_prekey: KeyPair, + pub signed_prekey_signature: [u8; 64], + /// Next one-time prekey ID to hand out. WhatsApp IDs are 24-bit. + pub next_prekey_id: u32, +} + +/// Everything the WhatsApp client needs to register and pair the device. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicIdentity { + pub registration_id: u32, + /// 32-byte Curve25519 public key, without the type byte. + pub identity_key: [u8; 32], + pub signed_prekey: PublicSignedPreKey, + /// Shared with the primary through the QR code; an HMAC key for pairing, + /// not a decryption key. + pub adv_secret: [u8; 32], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicSignedPreKey { + pub id: u32, + pub public_key: [u8; 32], + pub signature: [u8; 64], +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicPreKey { + pub id: u32, + pub public_key: [u8; 32], +} + +const FORMAT_V1: u8 = 1; +const ENCODED_LEN: usize = 1 + 32 + 4 + 32 + 4 + 32 + 64 + 4; +/// One-time prekey IDs are 3 bytes on the wire. +pub(crate) const MAX_PREKEY_ID: u32 = 0x00FF_FFFF; + +fn public32(key: &PublicKey) -> [u8; 32] { + let mut out = [0u8; 32]; + out.copy_from_slice(key.public_key_bytes()); + out +} + +impl DeviceKeys { + /// Fresh keys, generated the way wacore's own `Device::new` does. + pub fn generate() -> Result { + let mut rng = rand::make_rng::(); + let identity_pair = IdentityKeyPair::generate(&mut rng); + let identity = KeyPair::new( + *identity_pair.public_key(), + identity_pair.private_key().clone(), + ); + let signed_prekey = KeyPair::generate(&mut rng); + let signed_prekey_signature = sign_prekey(&identity, &signed_prekey)?; + let mut adv_secret = [0u8; 32]; + rng.fill_bytes(&mut adv_secret); + Ok(Self { + identity, + registration_id: rng.random_range(1..=2_147_483_647), + adv_secret, + signed_prekey_id: 1, + signed_prekey, + signed_prekey_signature, + next_prekey_id: 1, + }) + } + + pub fn identity_pair(&self) -> IdentityKeyPair { + self.identity.clone().into() + } + + pub fn public(&self) -> PublicIdentity { + PublicIdentity { + registration_id: self.registration_id, + identity_key: public32(&self.identity.public_key), + signed_prekey: PublicSignedPreKey { + id: self.signed_prekey_id, + public_key: public32(&self.signed_prekey.public_key), + signature: self.signed_prekey_signature, + }, + adv_secret: self.adv_secret, + } + } + + /// Layout, format 1: version, identity private, registration ID, ADV + /// secret, signed prekey ID, signed prekey private, signature, next + /// prekey ID. Integers are big-endian. Public keys are derived on load. + pub fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(ENCODED_LEN); + out.push(FORMAT_V1); + out.extend_from_slice(self.identity.private_key.serialize()); + out.extend_from_slice(&self.registration_id.to_be_bytes()); + out.extend_from_slice(&self.adv_secret); + out.extend_from_slice(&self.signed_prekey_id.to_be_bytes()); + out.extend_from_slice(self.signed_prekey.private_key.serialize()); + out.extend_from_slice(&self.signed_prekey_signature); + out.extend_from_slice(&self.next_prekey_id.to_be_bytes()); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + let invalid = |what: &str| OpsError::InvalidInput(format!("device keys: {what}")); + if bytes.first() != Some(&FORMAT_V1) { + return Err(invalid("unsupported format")); + } + if bytes.len() != ENCODED_LEN { + return Err(invalid("wrong length")); + } + let mut at = 1; + let mut take = |len: usize| { + let slice = &bytes[at..at + len]; + at += len; + slice + }; + let key_pair = |private: &[u8]| -> Result { + let private = PrivateKey::deserialize(private).map_err(|_| invalid("private key"))?; + let public = private.public_key().map_err(|_| invalid("public key"))?; + Ok(KeyPair::new(public, private)) + }; + let u32_at = |s: &[u8]| u32::from_be_bytes([s[0], s[1], s[2], s[3]]); + + let identity = key_pair(take(32))?; + let registration_id = u32_at(take(4)); + let adv_secret: [u8; 32] = take(32).try_into().map_err(|_| invalid("adv secret"))?; + let signed_prekey_id = u32_at(take(4)); + let signed_prekey = key_pair(take(32))?; + let signed_prekey_signature: [u8; 64] = + take(64).try_into().map_err(|_| invalid("signature"))?; + let next_prekey_id = u32_at(take(4)); + Ok(Self { + identity, + registration_id, + adv_secret, + signed_prekey_id, + signed_prekey, + signed_prekey_signature, + next_prekey_id, + }) + } +} + +/// WhatsApp signs the serialized (type-prefixed) signed prekey public key. +pub(crate) fn sign_prekey(identity: &KeyPair, prekey: &KeyPair) -> Result<[u8; 64], OpsError> { + let mut rng = rand::make_rng::(); + let signature = identity + .private_key + .calculate_signature(&prekey.public_key.serialize(), &mut rng) + .map_err(|e| OpsError::InvalidInput(format!("signing the prekey failed: {e}")))?; + signature + .as_ref() + .try_into() + .map_err(|_| OpsError::InvalidInput("signature is not 64 bytes".into())) +} + +pub(crate) fn public_bytes(key: &PublicKey) -> [u8; 32] { + public32(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_round_trip_through_the_encoding() { + let keys = DeviceKeys::generate().unwrap(); + let decoded = DeviceKeys::decode(&keys.encode()).unwrap(); + assert_eq!(decoded.public(), keys.public()); + assert_eq!(decoded.next_prekey_id, keys.next_prekey_id); + assert_eq!( + decoded.identity.private_key.serialize(), + keys.identity.private_key.serialize() + ); + } + + #[test] + fn the_signed_prekey_signature_verifies_against_the_identity() { + let keys = DeviceKeys::generate().unwrap(); + assert!(keys.identity.public_key.verify_signature( + &keys.signed_prekey.public_key.serialize(), + &keys.signed_prekey_signature + )); + } + + #[test] + fn corrupt_encodings_are_rejected() { + let mut bytes = DeviceKeys::generate().unwrap().encode(); + assert!(DeviceKeys::decode(&bytes[..10]).is_err()); + bytes[0] = 9; + assert!(DeviceKeys::decode(&bytes).is_err()); + } +} diff --git a/wacore/signal-ops/src/error.rs b/wacore/signal-ops/src/error.rs new file mode 100644 index 00000000..6a11db9a --- /dev/null +++ b/wacore/signal-ops/src/error.rs @@ -0,0 +1,34 @@ +use wacore::libsignal::protocol::SignalProtocolError; +use wacore::store::error::StoreError; + +#[derive(Debug, thiserror::Error)] +pub enum OpsError { + #[error("signal protocol error")] + Signal(#[from] SignalProtocolError), + #[error("store error")] + Store(#[from] StoreError), + /// The caller sent something this operation cannot accept. + #[error("invalid input: {0}")] + InvalidInput(String), + /// Pairing was refused. `code` and `text` are the values WhatsApp Web + /// answers the server with for the same failure. + #[error("pairing refused: {code} {text}")] + Pairing { code: u16, text: &'static str }, + /// The scope has no device keys yet; call `SignalOps::create`. + #[error("device keys are not initialised for this scope")] + NotInitialised, +} + +impl OpsError { + /// The store refused a write because another node now holds the scope. + /// The caller must stop serving it. + pub fn is_fence_lost(&self) -> bool { + match self { + OpsError::Store(e) => wacore_recordstore::is_fence_lost(e), + OpsError::Signal(SignalProtocolError::BackendError(_, source)) => source + .downcast_ref::() + .is_some_and(wacore_recordstore::is_fence_lost), + _ => false, + } + } +} diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs new file mode 100644 index 00000000..a9925d3a --- /dev/null +++ b/wacore/signal-ops/src/lib.rs @@ -0,0 +1,23 @@ +//! Signal operations for a WhatsApp linked device whose private keys live +//! outside the WhatsApp client. +//! +//! The client (connection, receipts, retries, prekey upload, device lists) +//! runs elsewhere and calls these operations whenever it needs a private key: +//! decrypting and encrypting messages, sender keys, new prekeys, and the one +//! pairing signature. Everything here is pure Signal state over a +//! [`wacore::store::traits::SignalStore`]; nothing opens a socket. +//! +//! Message padding is the caller's concern: [`SignalOps::decrypt`] returns +//! libsignal's plaintext exactly as received and [`SignalOps::encrypt`] takes +//! already padded plaintext. + +mod device; +mod error; +mod ops; +mod stores; + +pub use device::{DeviceKeys, PublicIdentity, PublicPreKey, PublicSignedPreKey}; +pub use error::OpsError; +pub use ops::{ + Decrypted, DeviceKeyStore, EncKind, Encrypted, PairingSignature, RemoteBundle, SignalOps, +}; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs new file mode 100644 index 00000000..05b00fb1 --- /dev/null +++ b/wacore/signal-ops/src/ops.rs @@ -0,0 +1,433 @@ +use crate::device::{ + DeviceKeys, MAX_PREKEY_ID, PublicIdentity, PublicPreKey, PublicSignedPreKey, public_bytes, + sign_prekey, +}; +use crate::error::OpsError; +use crate::stores::{KeyedLocks, Stores}; +use async_lock::{Mutex, RwLock}; +use async_trait::async_trait; +use std::sync::Arc; +use wacore::libsignal::protocol::{ + CiphertextMessageType, DeviceId, IdentityKey, KeyPair, PreKeyBundle, PreKeySignalMessage, + ProtocolAddress, PublicKey, SenderKeyDistributionMessage, SignalMessage, UsePQRatchet, + create_sender_key_distribution_message, group_decrypt, group_encrypt, message_decrypt_prekey, + message_decrypt_signal, message_encrypt, process_prekey_bundle, + process_sender_key_distribution_message, +}; +use wacore::libsignal::store::record_helpers; +use wacore::libsignal::store::sender_key_name::SenderKeyName; +use wacore::pair::PairUtils; +use wacore::store::error::Result as StoreResult; +use wacore::store::traits::SignalStore; +use wacore_recordstore::{RecordSignalStore, RecordStore, Sealer}; + +/// Persistence for the local device's key blob ([`DeviceKeys::encode`]). +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait DeviceKeyStore: Send + Sync { + async fn load_device_keys(&self) -> StoreResult>>; + async fn save_device_keys(&self, blob: &[u8]) -> StoreResult<()>; +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl DeviceKeyStore for RecordSignalStore { + async fn load_device_keys(&self) -> StoreResult>> { + self.load_device().await + } + + async fn save_device_keys(&self, blob: &[u8]) -> StoreResult<()> { + self.save_device(blob).await + } +} + +/// Pairwise ciphertext kinds, named as in the `` attribute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncKind { + /// `pkmsg`: first message of a session, carries X3DH material. + PreKey, + /// `msg`: a message on an established session. + Message, +} + +impl EncKind { + pub fn wire(self) -> &'static str { + match self { + EncKind::PreKey => "pkmsg", + EncKind::Message => "msg", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Decrypted { + /// Padded plaintext exactly as libsignal produced it. + pub plaintext: Vec, + /// The sender's identity key differs from the one stored before. + pub identity_changed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Encrypted { + pub kind: EncKind, + pub ciphertext: Vec, +} + +/// A peer device's prekey bundle, as fetched by the client from the server. +#[derive(Debug, Clone)] +pub struct RemoteBundle { + pub registration_id: u32, + pub device_id: u32, + pub identity_key: [u8; 32], + pub signed_prekey_id: u32, + pub signed_prekey: [u8; 32], + pub signed_prekey_signature: [u8; 64], + pub prekey: Option<(u32, [u8; 32])>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PairingSignature { + /// `ADVSignedDeviceIdentity` with the device signature added, ready for the + /// pair-success response. + pub signed_identity: Vec, + pub key_index: u32, +} + +/// Signal operations for one scope (one linked device). +/// +/// Writes to a session or sender key are serialised per address inside this +/// value; the store's lease fencing protects against a second node. +pub struct SignalOps { + store: Arc, + keys: RwLock>, + /// Held while device keys are read-modify-written. + device_write: Mutex<()>, + address_locks: KeyedLocks, + sender_key_locks: Arc, +} + +fn rng() -> rand::rngs::StdRng { + rand::make_rng::() +} + +fn public_key(bytes: &[u8; 32], what: &str) -> Result { + PublicKey::from_djb_public_key_bytes(bytes) + .map_err(|_| OpsError::InvalidInput(format!("{what} is not a valid public key"))) +} + +impl SignalOps { + /// Open a scope that already has device keys. + pub async fn open(store: Arc) -> Result { + let blob = store + .load_device_keys() + .await? + .ok_or(OpsError::NotInitialised)?; + Ok(Self::with_keys(store, DeviceKeys::decode(&blob)?)) + } + + /// Generate and persist device keys for a new scope. Refuses to replace + /// existing keys: that would silently unlink the device. + pub async fn create(store: Arc) -> Result { + if store.load_device_keys().await?.is_some() { + return Err(OpsError::InvalidInput( + "scope already has device keys".into(), + )); + } + let keys = DeviceKeys::generate()?; + store.save_device_keys(&keys.encode()).await?; + Ok(Self::with_keys(store, keys)) + } + + fn with_keys(store: Arc, keys: DeviceKeys) -> Self { + Self { + store, + keys: RwLock::new(Arc::new(keys)), + device_write: Mutex::new(()), + address_locks: KeyedLocks::default(), + sender_key_locks: Arc::new(KeyedLocks::default()), + } + } + + async fn stores(&self) -> Stores { + Stores { + store: self.store.clone(), + keys: self.keys.read().await.clone(), + sender_key_locks: self.sender_key_locks.clone(), + } + } + + async fn replace_keys(&self, keys: DeviceKeys) -> Result<(), OpsError> { + self.store.save_device_keys(&keys.encode()).await?; + *self.keys.write().await = Arc::new(keys); + Ok(()) + } + + pub async fn public_identity(&self) -> PublicIdentity { + self.keys.read().await.public() + } + + /// Create `count` one-time prekeys and return their public halves for + /// upload. The ID counter advances before the keys are written, so a crash + /// leaves a gap rather than reusing an ID. + pub async fn generate_prekeys(&self, count: u32) -> Result, OpsError> { + if count == 0 || count > 812 { + // 812 is WhatsApp Web's upload ceiling per request. + return Err(OpsError::InvalidInput( + "prekey count must be 1 to 812".into(), + )); + } + let _guard = self.device_write.lock().await; + let mut keys = (**self.keys.read().await).clone(); + let first = keys.next_prekey_id; + let mut ids = Vec::with_capacity(count as usize); + let mut next = first; + for _ in 0..count { + ids.push(next); + next = if next >= MAX_PREKEY_ID { 1 } else { next + 1 }; + } + keys.next_prekey_id = next; + self.replace_keys(keys).await?; + + let mut rng = rng(); + let mut records = Vec::with_capacity(ids.len()); + let mut public = Vec::with_capacity(ids.len()); + for id in ids { + let pair = KeyPair::generate(&mut rng); + let structure = record_helpers::new_pre_key_record(id, &pair); + records.push(( + id, + bytes::Bytes::from(waproto::codec::pre_key_record_to_vec(&structure)), + )); + public.push(PublicPreKey { + id, + public_key: public_bytes(&pair.public_key), + }); + } + self.store.store_prekeys_batch(&records, false).await?; + Ok(public) + } + + /// Record that the client uploaded these prekeys. Consumed keys stay gone. + pub async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<(), OpsError> { + Ok(self.store.mark_prekeys_uploaded(ids).await?) + } + + /// Replace the signed prekey. The old one stays loadable by ID so prekey + /// messages minted against it still decrypt. + pub async fn rotate_signed_prekey(&self) -> Result { + let _guard = self.device_write.lock().await; + let mut keys = (**self.keys.read().await).clone(); + let retired = record_helpers::new_signed_pre_key_record( + keys.signed_prekey_id, + &keys.signed_prekey, + keys.signed_prekey_signature, + wacore::time::now_utc(), + ); + self.store + .store_signed_prekey( + keys.signed_prekey_id, + &waproto::codec::signed_pre_key_record_to_vec(&retired), + ) + .await?; + let pair = KeyPair::generate(&mut rng()); + keys.signed_prekey_signature = sign_prekey(&keys.identity, &pair)?; + keys.signed_prekey = pair; + keys.signed_prekey_id = if keys.signed_prekey_id >= MAX_PREKEY_ID { + 1 + } else { + keys.signed_prekey_id + 1 + }; + let public = keys.public().signed_prekey; + self.replace_keys(keys).await?; + Ok(public) + } + + pub async fn has_session(&self, address: &ProtocolAddress) -> Result { + Ok(self.store.has_session(address.as_str()).await?) + } + + /// Run X3DH against a fetched bundle so [`Self::encrypt`] can produce a + /// `pkmsg` for that device. + pub async fn establish_session( + &self, + address: &ProtocolAddress, + bundle: &RemoteBundle, + ) -> Result<(), OpsError> { + let lock = self.address_locks.get(address.as_str()); + let _guard = lock.lock().await; + let prekey = match bundle.prekey { + Some((id, key)) => Some((id.into(), public_key(&key, "prekey")?)), + None => None, + }; + let bundle = PreKeyBundle::new( + bundle.registration_id, + DeviceId::from(bundle.device_id), + prekey, + bundle.signed_prekey_id.into(), + public_key(&bundle.signed_prekey, "signed prekey")?, + bundle.signed_prekey_signature, + IdentityKey::new(public_key(&bundle.identity_key, "identity key")?), + )?; + let mut sessions = self.stores().await; + let mut identities = sessions.clone(); + process_prekey_bundle( + address, + &mut sessions, + &mut identities, + &bundle, + &mut rng(), + UsePQRatchet::No, + ) + .await?; + Ok(()) + } + + /// Decrypt one pairwise `` payload. + pub async fn decrypt( + &self, + sender: &ProtocolAddress, + kind: EncKind, + ciphertext: &[u8], + ) -> Result { + let lock = self.address_locks.get(sender.as_str()); + let _guard = lock.lock().await; + let mut sessions = self.stores().await; + let mut identities = sessions.clone(); + let result = match kind { + EncKind::PreKey => { + let message = PreKeySignalMessage::try_from(ciphertext)?; + let mut prekeys = sessions.clone(); + let signed_prekeys = sessions.clone(); + message_decrypt_prekey( + &message, + sender, + &mut sessions, + &mut identities, + &mut prekeys, + &signed_prekeys, + &mut rng(), + UsePQRatchet::No, + ) + .await? + } + EncKind::Message => { + let message = SignalMessage::try_from(ciphertext)?; + message_decrypt_signal(&message, sender, &mut sessions, &mut identities, &mut rng()) + .await? + } + }; + // The promoted session is already written; only now is it safe to + // delete the one-time prekey it consumed. + if let Some(id) = result.consumed_prekey_id { + self.store.remove_prekey(id.into()).await?; + } + Ok(Decrypted { + plaintext: result.plaintext, + identity_changed: matches!( + result.identity_change, + wacore::libsignal::protocol::IdentityChange::ReplacedExisting + ), + }) + } + + /// Encrypt padded plaintext for one device with an existing session. + pub async fn encrypt( + &self, + recipient: &ProtocolAddress, + plaintext: &[u8], + ) -> Result { + let lock = self.address_locks.get(recipient.as_str()); + let _guard = lock.lock().await; + let mut sessions = self.stores().await; + let mut identities = sessions.clone(); + let message = message_encrypt(plaintext, recipient, &mut sessions, &mut identities).await?; + let kind = match message.message_type() { + CiphertextMessageType::PreKey => EncKind::PreKey, + CiphertextMessageType::Whisper => EncKind::Message, + other => { + return Err(OpsError::InvalidInput(format!( + "unexpected pairwise ciphertext type {other:?}" + ))); + } + }; + Ok(Encrypted { + kind, + ciphertext: message.serialize().to_vec(), + }) + } + + /// The sender-key distribution message for our own chain in `group`, + /// creating the chain if needed. Encrypt it pairwise to each member device. + pub async fn sender_key_distribution( + &self, + group: &str, + own_address: &ProtocolAddress, + ) -> Result, OpsError> { + let name = SenderKeyName::from_parts(group, own_address.as_str()); + let mut store = self.stores().await; + let skdm = create_sender_key_distribution_message(&name, &mut store, &mut rng()).await?; + Ok(skdm.serialized().to_vec()) + } + + /// Encrypt group plaintext (`skmsg`) on our own chain. + pub async fn group_encrypt( + &self, + group: &str, + own_address: &ProtocolAddress, + plaintext: &[u8], + ) -> Result, OpsError> { + let name = SenderKeyName::from_parts(group, own_address.as_str()); + let mut store = self.stores().await; + let message = group_encrypt(&mut store, &name, plaintext, &mut rng()).await?; + Ok(message.serialized().to_vec()) + } + + /// Store a member's sender key received in a decrypted message. + pub async fn process_sender_key_distribution( + &self, + group: &str, + sender: &ProtocolAddress, + skdm: &[u8], + ) -> Result<(), OpsError> { + let name = SenderKeyName::from_parts(group, sender.as_str()); + let message = SenderKeyDistributionMessage::try_from(skdm)?; + let mut store = self.stores().await; + process_sender_key_distribution_message(&name, &message, &mut store).await?; + Ok(()) + } + + /// Decrypt a group `skmsg` from `sender`. + pub async fn group_decrypt( + &self, + group: &str, + sender: &ProtocolAddress, + ciphertext: &[u8], + ) -> Result, OpsError> { + let name = SenderKeyName::from_parts(group, sender.as_str()); + let mut store = self.stores().await; + Ok(group_decrypt(ciphertext, &mut store, &name).await?) + } + + /// Verify the primary's pair-success container and add our device + /// signature. Signs only the ADV device-identity message built here, never + /// caller-chosen bytes. + pub async fn sign_pairing( + &self, + device_identity_container: &[u8], + ) -> Result { + let keys = self.keys.read().await.clone(); + let (signed_identity, key_index) = PairUtils::do_pair_crypto_with( + &keys.identity, + &keys.adv_secret, + device_identity_container, + ) + .map_err(|e| OpsError::Pairing { + code: e.code, + text: e.text, + })?; + Ok(PairingSignature { + signed_identity, + key_index, + }) + } +} diff --git a/wacore/signal-ops/src/stores.rs b/wacore/signal-ops/src/stores.rs new file mode 100644 index 00000000..15b0882c --- /dev/null +++ b/wacore/signal-ops/src/stores.rs @@ -0,0 +1,288 @@ +//! libsignal protocol stores over a byte-level [`SignalStore`]. +//! +//! Records use the same for-store encodings as wacore's own direct-write +//! adapters, so a scope written here reads back in any wacore consumer. + +use crate::device::DeviceKeys; +use async_lock::Mutex; +use async_trait::async_trait; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; +use wacore::libsignal::protocol::{ + Direction, GenericSignedPreKey, IdentityChange, IdentityKey, IdentityKeyPair, IdentityKeyStore, + PreKeyId, PreKeyRecord, PreKeyStore, ProtocolAddress, PublicKey, SenderKeyRecord, + SenderKeyStore, SessionRecord, SessionStore, SignalProtocolError, SignedPreKeyId, + SignedPreKeyRecord, SignedPreKeyStore, +}; +use wacore::libsignal::store::record_helpers; +use wacore::libsignal::store::sender_key_name::SenderKeyName; +use wacore::store::traits::SignalStore; + +type SignalResult = Result; + +/// One per process. Session and sender-key records carry the incarnation that +/// wrote them; a record read back under a different incarnation (another +/// process, or this one after a restart) is treated as possibly crashed, so +/// libsignal skips past chain positions it may already have used. +pub(crate) fn incarnation() -> &'static [u8; 16] { + static INCARNATION: OnceLock<[u8; 16]> = OnceLock::new(); + INCARNATION.get_or_init(|| { + use rand::Rng; + let mut bytes = [0u8; 16]; + rand::make_rng::().fill_bytes(&mut bytes); + bytes + }) +} + +fn backend(context: &'static str) -> impl FnOnce(E) -> SignalProtocolError +where + E: Into>, +{ + move |e| SignalProtocolError::BackendError(context, e.into()) +} + +pub(crate) struct Stores { + pub store: Arc, + pub keys: Arc, + pub sender_key_locks: Arc, +} + +impl Clone for Stores { + fn clone(&self) -> Self { + Self { + store: self.store.clone(), + keys: self.keys.clone(), + sender_key_locks: self.sender_key_locks.clone(), + } + } +} + +/// Lazily created per-key async mutexes. Entries are never removed; a node +/// serves a bounded set of chats per scope. +#[derive(Default)] +pub(crate) struct KeyedLocks { + locks: std::sync::Mutex>>>, +} + +impl KeyedLocks { + pub fn get(&self, key: &str) -> Arc> { + let mut locks = self + .locks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + locks + .entry(key.to_owned()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl SessionStore for Stores { + async fn load_session(&self, address: &ProtocolAddress) -> SignalResult> { + match self + .store + .get_session(address.as_str()) + .await + .map_err(backend("load_session"))? + { + Some(bytes) => Ok(Some(SessionRecord::deserialize_for_store( + &bytes, + incarnation(), + )?)), + None => Ok(None), + } + } + + async fn has_session(&self, address: &ProtocolAddress) -> SignalResult { + self.store + .has_session(address.as_str()) + .await + .map_err(backend("has_session")) + } + + async fn store_session( + &mut self, + address: &ProtocolAddress, + record: SessionRecord, + ) -> SignalResult<()> { + let mut bytes = Vec::new(); + record.serialize_into_for_store(&mut bytes, incarnation()); + self.store + .put_session(address.as_str(), &bytes) + .await + .map_err(backend("store_session")) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl IdentityKeyStore for Stores { + async fn get_identity_key_pair(&self) -> SignalResult { + Ok(self.keys.identity_pair()) + } + + async fn get_local_registration_id(&self) -> SignalResult { + Ok(self.keys.registration_id) + } + + async fn save_identity( + &mut self, + address: &ProtocolAddress, + identity: &IdentityKey, + ) -> SignalResult { + let existing = self.get_identity(address).await?; + let key: [u8; 32] = identity + .public_key() + .public_key_bytes() + .try_into() + .map_err(|_| SignalProtocolError::InvalidArgument("identity key length".into()))?; + self.store + .put_identity(address.as_str(), key) + .await + .map_err(backend("save_identity"))?; + Ok(match existing { + Some(previous) if &previous != identity => IdentityChange::ReplacedExisting, + _ => IdentityChange::NewOrUnchanged, + }) + } + + async fn is_trusted_identity( + &self, + _address: &ProtocolAddress, + _identity: &IdentityKey, + _direction: Direction, + ) -> SignalResult { + // Same policy as wacore's own adapters and WA Web: identity changes + // surface through save_identity, never by refusing the message. + Ok(true) + } + + async fn get_identity(&self, address: &ProtocolAddress) -> SignalResult> { + match self + .store + .load_identity(address.as_str()) + .await + .map_err(backend("get_identity"))? + { + Some(bytes) => Ok(Some(IdentityKey::new( + PublicKey::from_djb_public_key_bytes(&bytes)?, + ))), + None => Ok(None), + } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl PreKeyStore for Stores { + async fn get_pre_key(&self, prekey_id: PreKeyId) -> SignalResult { + let bytes = self + .store + .load_prekey(prekey_id.into()) + .await + .map_err(backend("get_pre_key"))? + .ok_or(SignalProtocolError::InvalidPreKeyId)?; + let structure = waproto::codec::pre_key_record_decode(&bytes) + .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)?; + record_helpers::prekey_structure_to_record(structure) + } + + async fn save_pre_key( + &mut self, + prekey_id: PreKeyId, + record: &PreKeyRecord, + ) -> SignalResult<()> { + let structure = record_helpers::prekey_record_to_structure(record)?; + self.store + .store_prekey( + prekey_id.into(), + &waproto::codec::pre_key_record_to_vec(&structure), + false, + ) + .await + .map_err(backend("save_pre_key")) + } + + async fn remove_pre_key(&mut self, prekey_id: PreKeyId) -> SignalResult<()> { + self.store + .remove_prekey(prekey_id.into()) + .await + .map_err(backend("remove_pre_key")) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl SignedPreKeyStore for Stores { + async fn get_signed_pre_key(&self, id: SignedPreKeyId) -> SignalResult { + let id: u32 = id.into(); + let structure = if id == self.keys.signed_prekey_id { + record_helpers::new_signed_pre_key_record( + id, + &self.keys.signed_prekey, + self.keys.signed_prekey_signature, + wacore::time::now_utc(), + ) + } else { + // A prekey message minted against a rotated-out signed prekey + // still names its old ID. + let bytes = self + .store + .load_signed_prekey(id) + .await + .map_err(backend("get_signed_pre_key"))? + .ok_or(SignalProtocolError::InvalidSignedPreKeyId)?; + waproto::codec::signed_pre_key_record_decode(&bytes) + .map_err(|_| SignalProtocolError::InvalidProtobufEncoding)? + }; + record_helpers::signed_prekey_structure_to_record(structure) + } + + async fn save_signed_pre_key( + &mut self, + id: SignedPreKeyId, + record: &SignedPreKeyRecord, + ) -> SignalResult<()> { + self.store + .store_signed_prekey(id.into(), &GenericSignedPreKey::serialize(record)?) + .await + .map_err(backend("save_signed_pre_key")) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl SenderKeyStore for Stores { + async fn store_sender_key( + &mut self, + name: &SenderKeyName, + record: SenderKeyRecord, + ) -> SignalResult<()> { + let bytes = record.serialize_for_store(incarnation())?; + self.store + .put_sender_key(name.cache_key(), &bytes) + .await + .map_err(backend("store_sender_key")) + } + + async fn load_sender_key(&self, name: &SenderKeyName) -> SignalResult> { + match self + .store + .get_sender_key(name.cache_key()) + .await + .map_err(backend("load_sender_key"))? + { + Some(bytes) => Ok(Some(SenderKeyRecord::deserialize_for_store( + &bytes, + incarnation(), + )?)), + None => Ok(None), + } + } + + async fn sender_key_lock(&self, name: &SenderKeyName) -> Arc> { + self.sender_key_locks.get(name.cache_key()) + } +} diff --git a/wacore/signal-ops/tests/end_to_end.rs b/wacore/signal-ops/tests/end_to_end.rs new file mode 100644 index 00000000..5f2d2d00 --- /dev/null +++ b/wacore/signal-ops/tests/end_to_end.rs @@ -0,0 +1,317 @@ +//! Two linked devices talking through real Signal sessions, each keeping its +//! state in a sealed, fenced record store. + +use futures::executor::block_on; +use std::sync::Arc; +use wacore::libsignal::protocol::{DeviceId, ProtocolAddress}; +use wacore::store::traits::SignalStore; +use wacore_recordstore::{ + AesGcmSealer, LeaseStore, MemoryRecordStore, Namespace, RecordSignalStore, RecordStore, +}; +use wacore_signal_ops::{EncKind, OpsError, RemoteBundle, SignalOps}; + +type Store = RecordSignalStore; + +const TTL: u64 = 60_000; +const DATA_KEY: [u8; 32] = [42; 32]; + +struct Node { + records: Arc, + scope: &'static str, + ops: SignalOps, +} + +fn store_for(records: &Arc, scope: &str, holder: &str, now: u64) -> Arc { + let lease = block_on(records.acquire(scope, holder, now, TTL)) + .unwrap() + .expect("lease available"); + Arc::new(RecordSignalStore::new( + records.clone(), + Arc::new(AesGcmSealer::new(&DATA_KEY)), + lease.fence, + )) +} + +fn new_device(scope: &'static str) -> Node { + let records = Arc::new(MemoryRecordStore::new()); + let store = store_for(&records, scope, "node-a", 0); + let ops = block_on(SignalOps::create(store)).unwrap(); + Node { + records, + scope, + ops, + } +} + +fn address(user: &str) -> ProtocolAddress { + ProtocolAddress::new(&format!("{user}@s.whatsapp.net"), DeviceId::from(1)) +} + +/// What the WhatsApp client would fetch from the server for `node`. +fn bundle_for(node: &Node, with_prekey: bool) -> (RemoteBundle, Option) { + let identity = block_on(node.ops.public_identity()); + let prekey = with_prekey.then(|| { + let keys = block_on(node.ops.generate_prekeys(1)).unwrap(); + (keys[0].id, keys[0].public_key) + }); + ( + RemoteBundle { + registration_id: identity.registration_id, + device_id: 1, + identity_key: identity.identity_key, + signed_prekey_id: identity.signed_prekey.id, + signed_prekey: identity.signed_prekey.public_key, + signed_prekey_signature: identity.signed_prekey.signature, + prekey, + }, + prekey.map(|(id, _)| id), + ) +} + +fn raw_records_contain(node: &Node, needle: &[u8]) -> bool { + Namespace::ALL.iter().any(|ns| { + let keys = block_on(node.records.scan_keys(node.scope, *ns, "", None)).unwrap(); + keys.iter().any(|key| { + let raw = block_on(node.records.get(node.scope, *ns, key)) + .unwrap() + .unwrap(); + raw.windows(needle.len()).any(|w| w == needle) + }) + }) +} + +#[test] +fn a_session_runs_both_ways_and_stays_sealed() { + let alice = new_device("alice-device"); + let bob = new_device("bob-device"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + + let (bundle, prekey_id) = bundle_for(&bob, true); + block_on(alice.ops.establish_session(&bob_addr, &bundle)).unwrap(); + + let first = block_on(alice.ops.encrypt(&bob_addr, b"hello bob")).unwrap(); + assert_eq!(first.kind, EncKind::PreKey); + let opened = block_on(bob.ops.decrypt(&alice_addr, first.kind, &first.ciphertext)).unwrap(); + assert_eq!(opened.plaintext, b"hello bob"); + + // The consumed one-time prekey is gone, after the session became durable. + let bob_store = store_for(&bob.records, bob.scope, "node-a", 1); + assert_eq!( + block_on(bob_store.load_prekey(prekey_id.unwrap())).unwrap(), + None + ); + + let reply = block_on(bob.ops.encrypt(&alice_addr, b"hi alice")).unwrap(); + assert_eq!(reply.kind, EncKind::Message); + let opened = block_on(alice.ops.decrypt(&bob_addr, reply.kind, &reply.ciphertext)).unwrap(); + assert_eq!(opened.plaintext, b"hi alice"); + + for round in 0..5u8 { + let to_bob = block_on(alice.ops.encrypt(&bob_addr, &[b'a', round])).unwrap(); + let got = block_on( + bob.ops + .decrypt(&alice_addr, to_bob.kind, &to_bob.ciphertext), + ) + .unwrap(); + assert_eq!(got.plaintext, [b'a', round]); + let to_alice = block_on(bob.ops.encrypt(&alice_addr, &[b'b', round])).unwrap(); + let got = block_on( + alice + .ops + .decrypt(&bob_addr, to_alice.kind, &to_alice.ciphertext), + ) + .unwrap(); + assert_eq!(got.plaintext, [b'b', round]); + } + + assert!(!raw_records_contain(&bob, b"hello bob")); + let bob_identity = block_on(bob.ops.public_identity()); + assert!( + !raw_records_contain(&bob, &bob_identity.adv_secret), + "device secrets reached the store unsealed" + ); +} + +#[test] +fn a_failed_over_node_keeps_the_session_and_the_old_node_is_fenced_out() { + let alice = new_device("alice-failover"); + let bob = new_device("bob-failover"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + let (bundle, _) = bundle_for(&bob, true); + block_on(alice.ops.establish_session(&bob_addr, &bundle)).unwrap(); + let first = block_on(alice.ops.encrypt(&bob_addr, b"before failover")).unwrap(); + block_on(bob.ops.decrypt(&alice_addr, first.kind, &first.ciphertext)).unwrap(); + + // node-a's lease lapses; node-b takes the scope from the same records. + let takeover = store_for(&bob.records, bob.scope, "node-b", TTL + 1); + let bob_b = block_on(SignalOps::open(takeover)).unwrap(); + + let next = block_on(alice.ops.encrypt(&bob_addr, b"after failover")).unwrap(); + let stale = block_on(bob.ops.decrypt(&alice_addr, next.kind, &next.ciphertext)); + let err = stale.expect_err("the superseded node must not advance the ratchet"); + assert!(err.is_fence_lost(), "unexpected error: {err:?}"); + + let opened = block_on(bob_b.decrypt(&alice_addr, next.kind, &next.ciphertext)).unwrap(); + assert_eq!(opened.plaintext, b"after failover"); +} + +#[test] +fn group_messages_decrypt_with_a_distributed_sender_key() { + let alice = new_device("alice-group"); + let bob = new_device("bob-group"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + let group = "120363000000001@g.us"; + let (bundle, _) = bundle_for(&bob, true); + block_on(alice.ops.establish_session(&bob_addr, &bundle)).unwrap(); + + let skdm = block_on(alice.ops.sender_key_distribution(group, &alice_addr)).unwrap(); + let carried = block_on(alice.ops.encrypt(&bob_addr, &skdm)).unwrap(); + let received = block_on( + bob.ops + .decrypt(&alice_addr, carried.kind, &carried.ciphertext), + ) + .unwrap(); + block_on( + bob.ops + .process_sender_key_distribution(group, &alice_addr, &received.plaintext), + ) + .unwrap(); + + let skmsg = block_on(alice.ops.group_encrypt(group, &alice_addr, b"hello group")).unwrap(); + let plaintext = block_on(bob.ops.group_decrypt(group, &alice_addr, &skmsg)).unwrap(); + assert_eq!(plaintext, b"hello group"); +} + +#[test] +fn a_prekey_message_for_a_rotated_signed_prekey_still_decrypts() { + let alice = new_device("alice-rotate"); + let bob = new_device("bob-rotate"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + let (bundle, _) = bundle_for(&bob, false); + let before = block_on(bob.ops.public_identity()).signed_prekey; + block_on(alice.ops.establish_session(&bob_addr, &bundle)).unwrap(); + + let after = block_on(bob.ops.rotate_signed_prekey()).unwrap(); + assert_ne!(after.id, before.id); + + let first = block_on(alice.ops.encrypt(&bob_addr, b"old signed prekey")).unwrap(); + let opened = block_on(bob.ops.decrypt(&alice_addr, first.kind, &first.ciphertext)).unwrap(); + assert_eq!(opened.plaintext, b"old signed prekey"); +} + +#[test] +fn reopening_a_scope_restores_the_same_device() { + let bob = new_device("bob-reopen"); + let identity = block_on(bob.ops.public_identity()); + let reopened = block_on(SignalOps::open(store_for( + &bob.records, + bob.scope, + "node-a", + 1, + ))) + .unwrap(); + assert_eq!(block_on(reopened.public_identity()), identity); + let again = block_on(SignalOps::create(store_for( + &bob.records, + bob.scope, + "node-a", + 2, + ))); + assert!( + matches!(again, Err(OpsError::InvalidInput(_))), + "create overwrote existing keys" + ); +} + +/// A pair-success container as the primary phone builds it: the account key +/// signs our identity, and the whole blob is HMACed with the QR's ADV secret. +// Test-only protos with no waproto::codec helper; the repo allows this in tests. +#[allow(clippy::disallowed_methods)] +fn pair_success_container( + device_identity: [u8; 32], + adv_secret: &[u8; 32], + key_index: u32, +) -> (Vec, wacore::libsignal::protocol::KeyPair) { + use buffa::Message; + use hmac::{KeyInit, Mac}; + use wacore::libsignal::protocol::{KeyPair, PublicKey}; + use waproto::whatsapp as wa; + + let mut rng = rand::make_rng::(); + let account = KeyPair::generate(&mut rng); + // Only the public half is read when no device prefix is requested. + let device = KeyPair::new( + PublicKey::from_djb_public_key_bytes(&device_identity).unwrap(), + KeyPair::generate(&mut rng).private_key, + ); + let details = wa::ADVDeviceIdentity { + raw_id: Some(1), + timestamp: Some(0), + key_index: Some(key_index), + account_type: Some(wa::ADVEncryptionType::E2EE), + device_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + let signed = wacore::adv::test_util::signed_identity( + &account, + &device, + &details, + wacore::adv::test_util::account_prefix(Some(wa::ADVEncryptionType::E2EE)), + None, + true, + ) + .encode_to_vec(); + let mut mac = as KeyInit>::new_from_slice(adv_secret).unwrap(); + mac.update(&signed); + let container = wa::ADVSignedDeviceIdentityHMAC { + details: Some(signed), + hmac: Some(mac.finalize().into_bytes().to_vec()), + account_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + (container, account) +} + +#[test] +fn pairing_signs_a_valid_container_with_the_device_prefix() { + let bob = new_device("bob-pairing-ok"); + let identity = block_on(bob.ops.public_identity()); + let (container, account) = + pair_success_container(identity.identity_key, &identity.adv_secret, 7); + + let signed = block_on(bob.ops.sign_pairing(&container)).unwrap(); + assert_eq!(signed.key_index, 7); + + let decoded = + waproto::codec::adv_signed_device_identity_decode(&signed.signed_identity).unwrap(); + let details = decoded.details.as_deref().unwrap(); + let message = [ + &[6u8, 1][..], + details, + &identity.identity_key, + account.public_key.public_key_bytes(), + ] + .concat(); + let device_key = + wacore::libsignal::protocol::PublicKey::from_djb_public_key_bytes(&identity.identity_key) + .unwrap(); + assert!(device_key.verify_signature(&message, decoded.device_signature.as_deref().unwrap())); +} + +#[test] +fn pairing_refuses_a_container_for_another_secret() { + let bob = new_device("bob-pairing-secret"); + let identity = block_on(bob.ops.public_identity()); + let (container, _) = pair_success_container(identity.identity_key, &[1; 32], 7); + assert!(matches!( + block_on(bob.ops.sign_pairing(&container)), + Err(OpsError::Pairing { code: 401, .. }) + )); +} + +#[test] +fn pairing_refuses_a_forged_container() { + let bob = new_device("bob-pairing"); + let refused = block_on(bob.ops.sign_pairing(b"not a pairing container")); + assert!(matches!(refused, Err(OpsError::Pairing { .. }))); +} From 1ea1879171581a29fdcff9ed79356f3e4b7b43b2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 9 Oct 2026 04:02:15 +0300 Subject: [PATCH 04/17] feat(signal-wasm): browser and edge bindings for Signal operations Add wacore-signal-wasm, a wasm-bindgen wrapper around wacore-signal-ops. The page supplies a RecordBackend (IndexedDB in a browser) and a Sealer (WebCrypto, so the data key can stay non-extractable); SignalDevice takes the scope's lease and exposes decrypt/encrypt, session setup, sender keys, prekeys, signed prekey rotation, pairing and lease renewal. Errors carry a code: fence_lost, lease_held, pairing_refused, invalid_input, signal, store. The crate compiles only for wasm32. CI builds it, generates Node glue with the wasm-bindgen CLI pinned to Cargo.lock, and runs two devices end to end from JavaScript with a WebCrypto sealer, including a second tab being refused and then fencing out the first. --- .github/workflows/wasm.yml | 15 + Cargo.lock | 25 ++ Cargo.toml | 1 + wacore/signal-wasm/Cargo.toml | 28 ++ wacore/signal-wasm/src/adapters.rs | 297 +++++++++++++++++++ wacore/signal-wasm/src/convert.rs | 208 +++++++++++++ wacore/signal-wasm/src/lib.rs | 386 +++++++++++++++++++++++++ wacore/signal-wasm/tests/node.test.mjs | 239 +++++++++++++++ 8 files changed, 1199 insertions(+) create mode 100644 wacore/signal-wasm/Cargo.toml create mode 100644 wacore/signal-wasm/src/adapters.rs create mode 100644 wacore/signal-wasm/src/convert.rs create mode 100644 wacore/signal-wasm/src/lib.rs create mode 100644 wacore/signal-wasm/tests/node.test.mjs diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index ba238456..6c9a4693 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -102,6 +102,21 @@ jobs: cargo build -p wacore-signal-ops --lib --release --target wasm32-unknown-unknown --features wacore/js + # The JS bindings are only proven by running them: build, generate the + # Node glue with the wasm-bindgen CLI matching Cargo.lock, and drive two + # devices end to end from JavaScript. + - name: Install wasm-bindgen CLI + uses: taiki-e/install-action@v2 + with: + tool: wasm-bindgen@0.2.128 + - name: Test wacore-signal-wasm bindings from Node + env: + RUSTFLAGS: '--cfg getrandom_backend="wasm_js"' + run: | + cargo build -p wacore-signal-wasm --lib --release --target wasm32-unknown-unknown + wasm-bindgen --target nodejs --out-dir target/signal-wasm-node \ + target/wasm32-unknown-unknown/release/wacore_signal_wasm.wasm + node --test wacore/signal-wasm/tests/node.test.mjs - name: Guard sqlite-storage's platform shim run: cargo xt ci workflow guard-sqlite-shim # The sans-IO VoIP core (wacore::voip: the CallEngine, the portable run_call driver, diff --git a/Cargo.lock b/Cargo.lock index 3a6b796a..35e2a68c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5354,6 +5354,21 @@ dependencies = [ "waproto", ] +[[package]] +name = "wacore-signal-wasm" +version = "0.1.0" +dependencies = [ + "async-trait", + "getrandom 0.4.3", + "js-sys", + "send_wrapper", + "wacore", + "wacore-recordstore", + "wacore-signal-ops", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -5425,6 +5440,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.128" diff --git a/Cargo.toml b/Cargo.toml index 393f13cb..fa3cc06c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ members = [ "wacore/noise", "wacore/recordstore", "wacore/signal-ops", + "wacore/signal-wasm", "waproto", ] default-members = [ diff --git a/wacore/signal-wasm/Cargo.toml b/wacore/signal-wasm/Cargo.toml new file mode 100644 index 00000000..2d6e2083 --- /dev/null +++ b/wacore/signal-wasm/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "wacore-signal-wasm" +version = "0.1.0" +edition = "2024" +rust-version.workspace = true +authors = ["Polymorfa"] +license = "MIT" +repository = "https://github.com/polymorfa/whatsapp-rust" +description = "Browser and edge bindings for wacore-signal-ops" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +# The bindings exist only on wasm32; on other targets the crate is empty, so a +# native `cargo check --workspace` stays green without JS types. +[target.'cfg(target_arch = "wasm32")'.dependencies] +async-trait = { workspace = true } +js-sys = "0.3.105" +send_wrapper = "0.6" +wacore = { workspace = true } +wacore-recordstore = { path = "../recordstore" } +wacore-signal-ops = { path = "../signal-ops" } +wasm-bindgen = "0.2.128" +wasm-bindgen-futures = "0.4" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +getrandom = { workspace = true, features = ["wasm_js"] } diff --git a/wacore/signal-wasm/src/adapters.rs b/wacore/signal-wasm/src/adapters.rs new file mode 100644 index 00000000..7d6775ab --- /dev/null +++ b/wacore/signal-wasm/src/adapters.rs @@ -0,0 +1,297 @@ +//! `RecordStore`, `LeaseStore` and `Sealer` backed by JS objects. +//! +//! wasm32 is single-threaded here, so the JS handles are wrapped in +//! `SendWrapper` only to satisfy the store traits' `Send + Sync` bounds; they +//! are never touched off the page's thread. + +use crate::convert::{fence_to_js, lease_to_js, to_lease}; +use async_trait::async_trait; +use js_sys::{Array, Object, Promise, Reflect, Uint8Array}; +use send_wrapper::SendWrapper; +use wacore::store::error::{Result, StoreError}; +use wacore_recordstore::{ + Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, SealError, WriteOp, +}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::JsFuture; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "RecordBackend")] + pub type RecordBackend; + + #[wasm_bindgen(method, catch)] + fn get( + this: &RecordBackend, + scope: &str, + ns: &str, + key: &str, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch, js_name = scanKeys)] + fn scan_keys( + this: &RecordBackend, + scope: &str, + ns: &str, + prefix: &str, + limit: Option, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch)] + fn write( + this: &RecordBackend, + fence: JsValue, + ops: Array, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch)] + fn acquire( + this: &RecordBackend, + scope: &str, + holder: &str, + now_ms: f64, + ttl_ms: f64, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch)] + fn renew( + this: &RecordBackend, + lease: JsValue, + now_ms: f64, + ttl_ms: f64, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch)] + fn release(this: &RecordBackend, lease: JsValue) -> std::result::Result; + + #[wasm_bindgen(typescript_type = "Sealer")] + pub type Sealer; + + #[wasm_bindgen(method, catch)] + fn seal( + this: &Sealer, + aad: &Uint8Array, + plaintext: &Uint8Array, + ) -> std::result::Result; + + #[wasm_bindgen(method, catch)] + fn open( + this: &Sealer, + aad: &Uint8Array, + sealed: &Uint8Array, + ) -> std::result::Result; +} + +/// A JS failure carried as a store error. Holds only the message, never the +/// value, so record bytes cannot leak into logs through an error. +#[derive(Debug)] +struct JsFailure(String); + +impl std::fmt::Display for JsFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for JsFailure {} + +fn describe(error: &JsValue) -> String { + Reflect::get(error, &JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()) + .or_else(|| error.as_string()) + .unwrap_or_else(|| "JavaScript error".to_owned()) +} + +fn is_fence_lost(error: &JsValue) -> bool { + Reflect::get(error, &JsValue::from_str("code")) + .ok() + .and_then(|c| c.as_string()) + .is_some_and(|c| c == "fence_lost") +} + +fn store_error(error: JsValue) -> StoreError { + StoreError::Database(Box::new(JsFailure(describe(&error)))) +} + +async fn settle( + call: std::result::Result, +) -> std::result::Result { + JsFuture::from(call?).await +} + +pub struct JsRecords(SendWrapper); + +impl JsRecords { + pub fn new(backend: RecordBackend) -> Self { + Self(SendWrapper::new(backend)) + } +} + +fn op_to_js(op: &WriteOp) -> JsValue { + let out = Object::new(); + let (kind, ns, key, value) = match op { + WriteOp::Put { ns, key, value } => ("put", ns, key, Some(value)), + WriteOp::Update { ns, key, value } => ("update", ns, key, Some(value)), + WriteOp::Delete { ns, key } => ("delete", ns, key, None), + }; + crate::convert::set(&out, "op", &JsValue::from_str(kind)); + crate::convert::set(&out, "ns", &JsValue::from_str(ns.tag())); + crate::convert::set(&out, "key", &JsValue::from_str(key)); + if let Some(value) = value { + crate::convert::set(&out, "value", &Uint8Array::from(value.as_slice())); + } + out.into() +} + +fn optional_lease(value: JsValue) -> Result> { + if value.is_null() || value.is_undefined() { + return Ok(None); + } + to_lease(&value).map(Some).map_err(StoreError::Validation) +} + +#[async_trait(?Send)] +impl RecordStore for JsRecords { + async fn get(&self, scope: &str, ns: Namespace, key: &str) -> Result>> { + let value = settle(self.0.get(scope, ns.tag(), key)) + .await + .map_err(store_error)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let array: Uint8Array = value.dyn_into().map_err(|_| { + StoreError::Validation("record backend returned a non-Uint8Array".into()) + })?; + Ok(Some(array.to_vec())) + } + + async fn scan_keys( + &self, + scope: &str, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> Result> { + let limit = limit.map(|l| u32::try_from(l).unwrap_or(u32::MAX)); + let value = settle(self.0.scan_keys(scope, ns.tag(), prefix, limit)) + .await + .map_err(store_error)?; + let array: Array = value + .dyn_into() + .map_err(|_| StoreError::Validation("scanKeys must resolve to an array".into()))?; + let mut keys = Vec::with_capacity(array.length() as usize); + for item in array.iter() { + keys.push( + item.as_string().ok_or_else(|| { + StoreError::Validation("scanKeys returned a non-string".into()) + })?, + ); + } + // The contract is ascending byte order; sort rather than trust it, + // since prekey ID lookups depend on it. + keys.sort(); + if let Some(limit) = limit { + keys.truncate(limit as usize); + } + Ok(keys) + } + + async fn write(&self, fence: &Fence, ops: &[WriteOp]) -> Result<()> { + let list = Array::new(); + for op in ops { + list.push(&op_to_js(op)); + } + settle(self.0.write(fence_to_js(fence), list)) + .await + .map(|_| ()) + .map_err(|error| { + if is_fence_lost(&error) { + FenceLost { + scope: fence.scope.clone(), + generation: fence.generation, + } + .into_store_error() + } else { + store_error(error) + } + }) + } +} + +#[async_trait(?Send)] +impl LeaseStore for JsRecords { + async fn acquire( + &self, + scope: &str, + holder: &str, + now_ms: u64, + ttl_ms: u64, + ) -> Result> { + let value = settle(self.0.acquire(scope, holder, now_ms as f64, ttl_ms as f64)) + .await + .map_err(store_error)?; + optional_lease(value) + } + + async fn renew(&self, lease: &Lease, now_ms: u64, ttl_ms: u64) -> Result> { + let value = settle( + self.0 + .renew(lease_to_js(lease), now_ms as f64, ttl_ms as f64), + ) + .await + .map_err(store_error)?; + optional_lease(value) + } + + async fn release(&self, lease: &Lease) -> Result<()> { + settle(self.0.release(lease_to_js(lease))) + .await + .map(|_| ()) + .map_err(store_error) + } +} + +pub struct JsSealerAdapter(SendWrapper); + +impl JsSealerAdapter { + pub fn new(sealer: Sealer) -> Self { + Self(SendWrapper::new(sealer)) + } +} + +async fn sealer_bytes( + call: std::result::Result, + authenticate: bool, +) -> std::result::Result, SealError> { + let value = settle(call).await.map_err(|error| { + if authenticate { + // WebCrypto reports a failed tag check as an OperationError; any + // rejection while opening means the record cannot be trusted. + SealError::Authentication + } else { + SealError::Backend(describe(&error)) + } + })?; + let array: Uint8Array = value + .dyn_into() + .map_err(|_| SealError::Backend("sealer must resolve to a Uint8Array".into()))?; + Ok(array.to_vec()) +} + +#[async_trait(?Send)] +impl wacore_recordstore::Sealer for JsSealerAdapter { + async fn seal(&self, aad: &[u8], plaintext: &[u8]) -> std::result::Result, SealError> { + let call = self + .0 + .seal(&Uint8Array::from(aad), &Uint8Array::from(plaintext)); + sealer_bytes(call, false).await + } + + async fn open(&self, aad: &[u8], sealed: &[u8]) -> std::result::Result, SealError> { + let call = self + .0 + .open(&Uint8Array::from(aad), &Uint8Array::from(sealed)); + sealer_bytes(call, true).await + } +} diff --git a/wacore/signal-wasm/src/convert.rs b/wacore/signal-wasm/src/convert.rs new file mode 100644 index 00000000..6f344214 --- /dev/null +++ b/wacore/signal-wasm/src/convert.rs @@ -0,0 +1,208 @@ +//! Conversions between JS values and the ops/record types. + +use js_sys::{Array, Error, Object, Reflect, Uint8Array}; +use std::sync::Arc; +use wacore::libsignal::protocol::{DeviceId, ProtocolAddress}; +use wacore_recordstore::{Fence, Lease}; +use wacore_signal_ops::{OpsError, PublicIdentity, PublicPreKey, PublicSignedPreKey, RemoteBundle}; +use wasm_bindgen::{JsCast, JsValue}; + +pub fn set(target: &Object, key: &str, value: &JsValue) { + // Setting a property on a plain object we just created cannot fail. + let _ = Reflect::set(target, &JsValue::from_str(key), value); +} + +fn get(source: &JsValue, key: &str) -> JsValue { + Reflect::get(source, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED) +} + +/// A JS `Error` with a machine-readable `code`. +pub fn coded_error(code: &str, message: &str) -> JsValue { + let error = Error::new(message); + set(&error, "code", &JsValue::from_str(code)); + error.into() +} + +pub fn ops_error(error: OpsError) -> JsValue { + if error.is_fence_lost() { + return coded_error("fence_lost", "another holder took this scope"); + } + match error { + OpsError::Pairing { code, text } => { + let js = coded_error("pairing_refused", text); + set( + js.unchecked_ref(), + "status", + &JsValue::from_f64(f64::from(code)), + ); + js + } + OpsError::InvalidInput(message) => coded_error("invalid_input", &message), + OpsError::NotInitialised => coded_error("not_initialised", "scope has no device keys"), + OpsError::Signal(e) => coded_error("signal", &e.to_string()), + OpsError::Store(e) => coded_error("store", &error_chain(&e)), + } +} + +fn error_chain(error: &dyn std::error::Error) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(inner) = source { + message.push_str(": "); + message.push_str(&inner.to_string()); + source = inner.source(); + } + message +} + +pub fn address(user: &str, device: u32) -> ProtocolAddress { + ProtocolAddress::new(user, DeviceId::from(device)) +} + +fn bytes(value: &[u8]) -> JsValue { + Uint8Array::from(value).into() +} + +pub fn signed_prekey(key: &PublicSignedPreKey) -> JsValue { + let out = Object::new(); + set(&out, "id", &JsValue::from_f64(f64::from(key.id))); + set(&out, "publicKey", &bytes(&key.public_key)); + set(&out, "signature", &bytes(&key.signature)); + out.into() +} + +pub fn public_identity(identity: &PublicIdentity) -> JsValue { + let out = Object::new(); + set( + &out, + "registrationId", + &JsValue::from_f64(f64::from(identity.registration_id)), + ); + set(&out, "identityKey", &bytes(&identity.identity_key)); + set( + &out, + "signedPrekey", + &signed_prekey(&identity.signed_prekey), + ); + set(&out, "advSecret", &bytes(&identity.adv_secret)); + out.into() +} + +pub fn public_prekeys(keys: &[PublicPreKey]) -> JsValue { + let list = Array::new(); + for key in keys { + let item = Object::new(); + set(&item, "id", &JsValue::from_f64(f64::from(key.id))); + set(&item, "publicKey", &bytes(&key.public_key)); + list.push(&item); + } + list.into() +} + +fn invalid(message: &str) -> JsValue { + coded_error("invalid_input", message) +} + +fn u32_field(source: &JsValue, key: &str) -> Result { + let value = get(source, key) + .as_f64() + .ok_or_else(|| invalid(&format!("{key} must be a number")))?; + if value.fract() != 0.0 || !(0.0..=f64::from(u32::MAX)).contains(&value) { + return Err(invalid(&format!("{key} must be a 32-bit unsigned integer"))); + } + Ok(value as u32) +} + +fn fixed_bytes(source: &JsValue, key: &str) -> Result<[u8; N], JsValue> { + let value = get(source, key); + let array: Uint8Array = value + .dyn_into() + .map_err(|_| invalid(&format!("{key} must be a Uint8Array")))?; + array + .to_vec() + .try_into() + .map_err(|_| invalid(&format!("{key} must be {N} bytes"))) +} + +pub fn remote_bundle(value: &JsValue) -> Result { + let prekey = if get(value, "prekey").is_undefined() || get(value, "prekey").is_null() { + None + } else { + Some((u32_field(value, "prekeyId")?, fixed_bytes(value, "prekey")?)) + }; + Ok(RemoteBundle { + registration_id: u32_field(value, "registrationId")?, + device_id: u32_field(value, "deviceId")?, + identity_key: fixed_bytes(value, "identityKey")?, + signed_prekey_id: u32_field(value, "signedPrekeyId")?, + signed_prekey: fixed_bytes(value, "signedPrekey")?, + signed_prekey_signature: fixed_bytes(value, "signedPrekeySignature")?, + prekey, + }) +} + +/// Generations and expiry times travel as JS numbers; both stay far below +/// 2^53 in practice, and the conversion rejects anything that is not. +fn safe_u64(value: f64, what: &str) -> Result { + if value.fract() != 0.0 || !(0.0..=9_007_199_254_740_991.0).contains(&value) { + return Err(format!("{what} must be a non-negative safe integer")); + } + Ok(value as u64) +} + +pub fn fence_to_js(fence: &Fence) -> JsValue { + let out = Object::new(); + set(&out, "scope", &JsValue::from_str(&fence.scope)); + set( + &out, + "generation", + &JsValue::from_f64(fence.generation as f64), + ); + out.into() +} + +pub fn lease_to_js(lease: &Lease) -> JsValue { + let out = Object::new(); + set(&out, "scope", &JsValue::from_str(&lease.fence.scope)); + set( + &out, + "generation", + &JsValue::from_f64(lease.fence.generation as f64), + ); + set(&out, "holder", &JsValue::from_str(&lease.holder)); + set( + &out, + "expiresAtMs", + &JsValue::from_f64(lease.expires_at_ms as f64), + ); + out.into() +} + +pub fn to_lease(value: &JsValue) -> Result { + let scope = get(value, "scope") + .as_string() + .ok_or("lease.scope must be a string")?; + let holder = get(value, "holder") + .as_string() + .ok_or("lease.holder must be a string")?; + let generation = safe_u64( + get(value, "generation") + .as_f64() + .ok_or("lease.generation must be a number")?, + "lease.generation", + )?; + let expires_at_ms = safe_u64( + get(value, "expiresAtMs") + .as_f64() + .ok_or("lease.expiresAtMs must be a number")?, + "lease.expiresAtMs", + )?; + Ok(Lease { + fence: Fence { + scope: Arc::from(scope), + generation, + }, + holder: Arc::from(holder), + expires_at_ms, + }) +} diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs new file mode 100644 index 00000000..6b59773b --- /dev/null +++ b/wacore/signal-wasm/src/lib.rs @@ -0,0 +1,386 @@ +//! Browser and edge bindings for [`wacore_signal_ops`]. +//! +//! The page supplies storage and sealing: a `RecordBackend` (IndexedDB in a +//! browser, anything in an edge worker) and a `Sealer` (WebCrypto, so the data +//! key can stay non-extractable). This crate supplies the Signal state machine. +#![cfg(target_arch = "wasm32")] + +mod adapters; +mod convert; + +use adapters::{JsRecords, JsSealerAdapter}; +use convert::{ + address, lease_to_js, ops_error, public_identity, public_prekeys, remote_bundle, set, + signed_prekey, +}; +use js_sys::{Object, Promise, Uint8Array, Uint32Array}; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; +use wacore_recordstore::{Lease, LeaseStore, RecordSignalStore}; +use wacore_signal_ops::{EncKind, OpsError, SignalOps}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::future_to_promise; + +#[wasm_bindgen(typescript_custom_section)] +const TS_TYPES: &str = r#" +export interface Fence { scope: string; generation: number } +export interface Lease { scope: string; generation: number; holder: string; expiresAtMs: number } +export type WriteOp = + | { op: "put" | "update"; ns: string; key: string; value: Uint8Array } + | { op: "delete"; ns: string; key: string }; + +/** + * Byte storage for one or more scopes. `write` applies every op or none, and + * only while `fence.generation` is the scope's current lease generation; + * otherwise it rejects with an error whose `code` is "fence_lost". An + * "update" op on a missing key is a no-op, never an insert. + */ +export interface RecordBackend { + get(scope: string, ns: string, key: string): Promise; + scanKeys(scope: string, ns: string, prefix: string, limit: number | undefined): Promise; + write(fence: Fence, ops: WriteOp[]): Promise; + acquire(scope: string, holder: string, nowMs: number, ttlMs: number): Promise; + renew(lease: Lease, nowMs: number, ttlMs: number): Promise; + release(lease: Lease): Promise; +} + +/** Authenticated encryption for record values. `aad` must be authenticated, not encrypted. */ +export interface Sealer { + seal(aad: Uint8Array, plaintext: Uint8Array): Promise; + open(aad: Uint8Array, sealed: Uint8Array): Promise; +} + +export interface RemoteBundle { + registrationId: number; + deviceId: number; + identityKey: Uint8Array; + signedPrekeyId: number; + signedPrekey: Uint8Array; + signedPrekeySignature: Uint8Array; + prekeyId?: number; + prekey?: Uint8Array; +} +"#; + +type Store = RecordSignalStore; + +struct Inner { + ops: SignalOps, + records: Arc, + lease: RefCell, +} + +/// One linked device's Signal state, held under a lease. +#[wasm_bindgen] +pub struct SignalDevice { + inner: Rc, +} + +fn promise(future: F) -> Promise +where + F: std::future::Future> + 'static, +{ + future_to_promise(future) +} + +#[wasm_bindgen] +impl SignalDevice { + /// Take the scope's lease and open its device. With `create`, a scope + /// without device keys gets new ones; existing keys are never replaced. + /// Rejects with code "lease_held" while another holder's lease is live. + #[wasm_bindgen(js_name = open)] + pub async fn open( + backend: adapters::RecordBackend, + sealer: adapters::Sealer, + scope: String, + holder: String, + now_ms: f64, + ttl_ms: f64, + create: bool, + ) -> Result { + let records = Arc::new(JsRecords::new(backend)); + let lease = records + .acquire(&scope, &holder, now_ms as u64, ttl_ms as u64) + .await + .map_err(|e| ops_error(OpsError::Store(e)))? + .ok_or_else(|| convert::coded_error("lease_held", "another holder owns this scope"))?; + let store = Arc::new(RecordSignalStore::new( + records.clone(), + Arc::new(JsSealerAdapter::new(sealer)), + lease.fence.clone(), + )); + let ops = match SignalOps::open(store.clone()).await { + Ok(ops) => ops, + Err(OpsError::NotInitialised) if create => { + SignalOps::create(store).await.map_err(ops_error)? + } + Err(e) => return Err(ops_error(e)), + }; + Ok(SignalDevice { + inner: Rc::new(Inner { + ops, + records, + lease: RefCell::new(lease), + }), + }) + } + + /// The current lease, for the caller's renewal timer. + #[wasm_bindgen(getter)] + pub fn lease(&self) -> JsValue { + lease_to_js(&self.inner.lease.borrow()) + } + + /// Extend the lease. Resolves `false` once another holder has taken the + /// scope; every later write would be refused, so stop using this device. + #[wasm_bindgen(js_name = renewLease)] + pub fn renew_lease(&self, now_ms: f64, ttl_ms: f64) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let current = inner.lease.borrow().clone(); + let renewed = inner + .records + .renew(¤t, now_ms as u64, ttl_ms as u64) + .await + .map_err(|e| ops_error(OpsError::Store(e)))?; + Ok(JsValue::from_bool(match renewed { + Some(lease) => { + *inner.lease.borrow_mut() = lease; + true + } + None => false, + })) + }) + } + + /// Give the scope up so another holder can take it at once. + pub fn release(&self) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let current = inner.lease.borrow().clone(); + inner + .records + .release(¤t) + .await + .map_err(|e| ops_error(OpsError::Store(e)))?; + Ok(JsValue::UNDEFINED) + }) + } + + #[wasm_bindgen(js_name = publicIdentity)] + pub fn public_identity(&self) -> Promise { + let inner = self.inner.clone(); + promise(async move { Ok(public_identity(&inner.ops.public_identity().await)) }) + } + + #[wasm_bindgen(js_name = generatePrekeys)] + pub fn generate_prekeys(&self, count: u32) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let keys = inner.ops.generate_prekeys(count).await.map_err(ops_error)?; + Ok(public_prekeys(&keys)) + }) + } + + #[wasm_bindgen(js_name = markPrekeysUploaded)] + pub fn mark_prekeys_uploaded(&self, ids: Uint32Array) -> Promise { + let inner = self.inner.clone(); + let ids = ids.to_vec(); + promise(async move { + inner + .ops + .mark_prekeys_uploaded(&ids) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + #[wasm_bindgen(js_name = rotateSignedPrekey)] + pub fn rotate_signed_prekey(&self) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let key = inner.ops.rotate_signed_prekey().await.map_err(ops_error)?; + Ok(signed_prekey(&key)) + }) + } + + #[wasm_bindgen(js_name = hasSession)] + pub fn has_session(&self, user: String, device: u32) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let found = inner + .ops + .has_session(&address(&user, device)) + .await + .map_err(ops_error)?; + Ok(JsValue::from_bool(found)) + }) + } + + /// Run X3DH against a bundle the client fetched from the server. + #[wasm_bindgen(js_name = establishSession)] + pub fn establish_session(&self, user: String, device: u32, bundle: JsValue) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let bundle = remote_bundle(&bundle)?; + inner + .ops + .establish_session(&address(&user, device), &bundle) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + /// Decrypt a pairwise payload. `kind` is the `` value. + pub fn decrypt(&self, user: String, device: u32, kind: String, ciphertext: Vec) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let kind = match kind.as_str() { + "pkmsg" => EncKind::PreKey, + "msg" => EncKind::Message, + other => { + return Err(convert::coded_error( + "invalid_input", + &format!("unsupported enc type {other}"), + )); + } + }; + let result = inner + .ops + .decrypt(&address(&user, device), kind, &ciphertext) + .await + .map_err(ops_error)?; + let out = Object::new(); + set( + &out, + "plaintext", + &Uint8Array::from(result.plaintext.as_slice()), + ); + set( + &out, + "identityChanged", + &JsValue::from_bool(result.identity_changed), + ); + Ok(out.into()) + }) + } + + /// Encrypt padded plaintext for one device with an established session. + pub fn encrypt(&self, user: String, device: u32, plaintext: Vec) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let result = inner + .ops + .encrypt(&address(&user, device), &plaintext) + .await + .map_err(ops_error)?; + let out = Object::new(); + set(&out, "kind", &JsValue::from_str(result.kind.wire())); + set( + &out, + "ciphertext", + &Uint8Array::from(result.ciphertext.as_slice()), + ); + Ok(out.into()) + }) + } + + #[wasm_bindgen(js_name = senderKeyDistribution)] + pub fn sender_key_distribution(&self, group: String, user: String, device: u32) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let skdm = inner + .ops + .sender_key_distribution(&group, &address(&user, device)) + .await + .map_err(ops_error)?; + Ok(Uint8Array::from(skdm.as_slice()).into()) + }) + } + + #[wasm_bindgen(js_name = groupEncrypt)] + pub fn group_encrypt( + &self, + group: String, + user: String, + device: u32, + plaintext: Vec, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let ciphertext = inner + .ops + .group_encrypt(&group, &address(&user, device), &plaintext) + .await + .map_err(ops_error)?; + Ok(Uint8Array::from(ciphertext.as_slice()).into()) + }) + } + + #[wasm_bindgen(js_name = processSenderKeyDistribution)] + pub fn process_sender_key_distribution( + &self, + group: String, + user: String, + device: u32, + skdm: Vec, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + inner + .ops + .process_sender_key_distribution(&group, &address(&user, device), &skdm) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + #[wasm_bindgen(js_name = groupDecrypt)] + pub fn group_decrypt( + &self, + group: String, + user: String, + device: u32, + ciphertext: Vec, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let plaintext = inner + .ops + .group_decrypt(&group, &address(&user, device), &ciphertext) + .await + .map_err(ops_error)?; + Ok(Uint8Array::from(plaintext.as_slice()).into()) + }) + } + + /// Verify the primary's pair-success container and add the device + /// signature. Rejects with code "pairing_refused" and a `status`. + #[wasm_bindgen(js_name = signPairing)] + pub fn sign_pairing(&self, container: Vec) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let signed = inner + .ops + .sign_pairing(&container) + .await + .map_err(ops_error)?; + let out = Object::new(); + set( + &out, + "signedIdentity", + &Uint8Array::from(signed.signed_identity.as_slice()), + ); + set( + &out, + "keyIndex", + &JsValue::from_f64(f64::from(signed.key_index)), + ); + Ok(out.into()) + }) + } +} diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs new file mode 100644 index 00000000..14451dac --- /dev/null +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -0,0 +1,239 @@ +// End-to-end check of the wasm32 bindings from JavaScript. +// +// Build first, from the repository root: +// RUSTFLAGS='--cfg getrandom_backend="wasm_js"' cargo build -p wacore-signal-wasm \ +// --lib --release --target wasm32-unknown-unknown +// wasm-bindgen --target nodejs --out-dir target/signal-wasm-node \ +// target/wasm32-unknown-unknown/release/wacore_signal_wasm.wasm +// Then: node --test wacore/signal-wasm/tests/node.test.mjs +// +// The backend below is an in-memory reference for the RecordBackend contract; +// the sealer is WebCrypto AES-GCM with a non-extractable key, as in a browser. + +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const pkg = + process.env.SIGNAL_WASM_PKG ?? + fileURLToPath( + new URL("../../../target/signal-wasm-node/wacore_signal_wasm.js", import.meta.url), + ); +const { SignalDevice } = require(pkg); + +const TTL = 60_000; + +class MemoryBackend { + records = new Map(); + leases = new Map(); + + #slot(scope, ns, key) { + return `${scope}\u0000${ns}\u0000${key}`; + } + + async get(scope, ns, key) { + return this.records.get(this.#slot(scope, ns, key)) ?? null; + } + + async scanKeys(scope, ns, prefix, limit) { + const head = `${scope}\u0000${ns}\u0000`; + const keys = [...this.records.keys()] + .filter((slot) => slot.startsWith(head + prefix)) + .map((slot) => slot.slice(head.length)) + .sort(); + return limit === undefined ? keys : keys.slice(0, limit); + } + + async write(fence, ops) { + if (this.leases.get(fence.scope)?.generation !== fence.generation) { + throw Object.assign(new Error("fence lost"), { code: "fence_lost" }); + } + for (const op of ops) { + const slot = this.#slot(fence.scope, op.ns, op.key); + if (op.op === "put") this.records.set(slot, op.value.slice()); + else if (op.op === "update") { + if (this.records.has(slot)) this.records.set(slot, op.value.slice()); + } else this.records.delete(slot); + } + } + + async acquire(scope, holder, nowMs, ttlMs) { + const row = this.leases.get(scope); + let generation; + if (!row) generation = 1; + else if (row.holder === holder) generation = row.generation; + else if (row.expiresAtMs > nowMs) return null; + else generation = row.generation + 1; + const lease = { scope, generation, holder, expiresAtMs: nowMs + ttlMs }; + this.leases.set(scope, lease); + return { ...lease }; + } + + async renew(lease, nowMs, ttlMs) { + const row = this.leases.get(lease.scope); + if (!row || row.generation !== lease.generation || row.holder !== lease.holder) return null; + row.expiresAtMs = nowMs + ttlMs; + return { ...row }; + } + + async release(lease) { + const row = this.leases.get(lease.scope); + if (row && row.generation === lease.generation && row.holder === lease.holder) { + row.expiresAtMs = 0; + row.holder = ""; + } + } + + containsBytes(needle) { + const hex = Buffer.from(needle).toString("hex"); + return [...this.records.values()].some((v) => Buffer.from(v).toString("hex").includes(hex)); + } +} + +async function webCryptoSealer() { + const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ + "encrypt", + "decrypt", + ]); + return { + async seal(aad, plaintext) { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const sealed = new Uint8Array( + await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, plaintext), + ); + const out = new Uint8Array(12 + sealed.length); + out.set(iv); + out.set(sealed, 12); + return out; + }, + async open(aad, sealed) { + return new Uint8Array( + await crypto.subtle.decrypt( + { name: "AES-GCM", iv: sealed.subarray(0, 12), additionalData: aad }, + key, + sealed.subarray(12), + ), + ); + }, + }; +} + +async function device(scope, { backend, sealer, holder = "tab-a", now = 0 } = {}) { + backend ??= new MemoryBackend(); + sealer ??= await webCryptoSealer(); + const signal = await SignalDevice.open(backend, sealer, scope, holder, now, TTL, true); + return { signal, backend, sealer }; +} + +async function bundleFor(signal, withPrekey = true) { + const identity = await signal.publicIdentity(); + const bundle = { + registrationId: identity.registrationId, + deviceId: 1, + identityKey: identity.identityKey, + signedPrekeyId: identity.signedPrekey.id, + signedPrekey: identity.signedPrekey.publicKey, + signedPrekeySignature: identity.signedPrekey.signature, + }; + if (withPrekey) { + const [prekey] = await signal.generatePrekeys(1); + bundle.prekeyId = prekey.id; + bundle.prekey = prekey.publicKey; + } + return bundle; +} + +const ALICE = "111@s.whatsapp.net"; +const BOB = "222@s.whatsapp.net"; +const text = (s) => new TextEncoder().encode(s); +const read = (b) => new TextDecoder().decode(b); + +test("a session runs both ways and nothing readable reaches storage", async () => { + const alice = await device("alice"); + const bob = await device("bob"); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + + const first = await alice.signal.encrypt(BOB, 1, text("hello bob")); + assert.equal(first.kind, "pkmsg"); + const opened = await bob.signal.decrypt(ALICE, 1, first.kind, first.ciphertext); + assert.equal(read(opened.plaintext), "hello bob"); + + const reply = await bob.signal.encrypt(ALICE, 1, text("hi alice")); + assert.equal(reply.kind, "msg"); + assert.equal(read((await alice.signal.decrypt(BOB, 1, reply.kind, reply.ciphertext)).plaintext), "hi alice"); + + for (let round = 0; round < 5; round++) { + const out = await alice.signal.encrypt(BOB, 1, text(`a${round}`)); + assert.equal(read((await bob.signal.decrypt(ALICE, 1, out.kind, out.ciphertext)).plaintext), `a${round}`); + } + + assert.equal(bob.backend.containsBytes(text("hello bob")), false); + const identity = await bob.signal.publicIdentity(); + assert.equal(bob.backend.containsBytes(identity.advSecret), false); +}); + +test("a second tab is refused while the lease is live, then fences the first out", async () => { + const alice = await device("alice-failover"); + const backend = new MemoryBackend(); + const sealer = await webCryptoSealer(); + const bobA = await device("bob-failover", { backend, sealer, holder: "tab-a", now: 0 }); + await alice.signal.establishSession(BOB, 1, await bundleFor(bobA.signal)); + const first = await alice.signal.encrypt(BOB, 1, text("before")); + await bobA.signal.decrypt(ALICE, 1, first.kind, first.ciphertext); + + await assert.rejects( + SignalDevice.open(backend, sealer, "bob-failover", "tab-b", 1, TTL, false), + (error) => error.code === "lease_held", + ); + + const bobB = await SignalDevice.open(backend, sealer, "bob-failover", "tab-b", TTL + 1, TTL, false); + const next = await alice.signal.encrypt(BOB, 1, text("after")); + await assert.rejects( + bobA.signal.decrypt(ALICE, 1, next.kind, next.ciphertext), + (error) => error.code === "fence_lost", + ); + assert.equal(await bobA.signal.renewLease(TTL + 2, TTL), false); + assert.equal(read((await bobB.decrypt(ALICE, 1, next.kind, next.ciphertext)).plaintext), "after"); +}); + +test("group messages decrypt with a distributed sender key", async () => { + const alice = await device("alice-group"); + const bob = await device("bob-group"); + const group = "120363000000001@g.us"; + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + + const skdm = await alice.signal.senderKeyDistribution(group, ALICE, 1); + const carried = await alice.signal.encrypt(BOB, 1, skdm); + const received = await bob.signal.decrypt(ALICE, 1, carried.kind, carried.ciphertext); + await bob.signal.processSenderKeyDistribution(group, ALICE, 1, received.plaintext); + + const skmsg = await alice.signal.groupEncrypt(group, ALICE, 1, text("hello group")); + assert.equal(read(await bob.signal.groupDecrypt(group, ALICE, 1, skmsg)), "hello group"); +}); + +test("a sealer with the wrong key cannot open the device", async () => { + const backend = new MemoryBackend(); + await device("bob-key", { backend }); + await assert.rejects( + SignalDevice.open(backend, await webCryptoSealer(), "bob-key", "tab-a", 1, TTL, false), + (error) => error.code === "store", + ); +}); + +test("pairing and input errors carry codes", async () => { + const bob = await device("bob-errors"); + await assert.rejects( + bob.signal.signPairing(text("not a container")), + (error) => error.code === "pairing_refused" && typeof error.status === "number", + ); + await assert.rejects( + bob.signal.decrypt(ALICE, 1, "skmsg", new Uint8Array(4)), + (error) => error.code === "invalid_input", + ); + await assert.rejects( + bob.signal.establishSession(ALICE, 1, { registrationId: 1 }), + (error) => error.code === "invalid_input", + ); +}); From 196a8597148e55bcf41ed8cb608f51be3ae61448 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 04:12:56 +0300 Subject: [PATCH 05/17] feat(signal-ops): atomic, idempotent receive for the Signal service Decrypt now runs against a staged view of the record store: the ratchet advance, the consumed one-time prekey's removal, any sender-key distribution carried in the message and the buffered result are sealed and written as one fenced batch. A node fenced out mid-receive commits nothing, so its successor still decrypts the same ciphertext. SignalOps::receive decrypts, buffers and classifies a payload with wacore's process_decrypted_plaintext. Redelivery before mark_delivered returns the buffered result; after it, AlreadyDelivered. App-state key shares are surfaced only from the account's own devices, as WA Web gates them. prune_decrypt_buffer drops old entries. wacore-recordstore gains staged views (RecordSignalStore::staged/commit) and DecryptBuffer/SentMessage namespaces reachable through a separate aux API. The wasm bindings expose receive, markDelivered and pruneDecryptBuffer. --- Cargo.lock | 2 + wacore/recordstore/src/record.rs | 11 +- wacore/recordstore/src/signal.rs | 437 ++++++++++++++++++++----- wacore/signal-ops/Cargo.toml | 2 + wacore/signal-ops/src/inbound.rs | 316 ++++++++++++++++++ wacore/signal-ops/src/lib.rs | 4 +- wacore/signal-ops/src/ops.rs | 112 ++++++- wacore/signal-ops/tests/receive.rs | 296 +++++++++++++++++ wacore/signal-wasm/Cargo.toml | 1 + wacore/signal-wasm/src/lib.rs | 104 +++++- wacore/signal-wasm/tests/node.test.mjs | 40 +++ 11 files changed, 1237 insertions(+), 88 deletions(-) create mode 100644 wacore/signal-ops/src/inbound.rs create mode 100644 wacore/signal-ops/tests/receive.rs diff --git a/Cargo.lock b/Cargo.lock index 35e2a68c..8f90af64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5345,6 +5345,7 @@ dependencies = [ "buffa", "bytes", "futures", + "hex", "hmac 0.13.0", "rand 0.10.3", "sha2 0.11.0", @@ -5365,6 +5366,7 @@ dependencies = [ "wacore", "wacore-recordstore", "wacore-signal-ops", + "waproto", "wasm-bindgen", "wasm-bindgen-futures", ] diff --git a/wacore/recordstore/src/record.rs b/wacore/recordstore/src/record.rs index 741a700c..4804bb4a 100644 --- a/wacore/recordstore/src/record.rs +++ b/wacore/recordstore/src/record.rs @@ -13,16 +13,23 @@ pub enum Namespace { /// The local device's own keys (identity, registration ID, ADV secret, /// current signed prekey). Read and written whole by the Signal service. Device, + /// Decrypted results kept until the app confirms delivery, so a + /// redelivered ciphertext returns the same result instead of failing. + DecryptBuffer, + /// Messages this device sent, kept so a retry receipt can re-encrypt them. + SentMessage, } impl Namespace { - pub const ALL: [Namespace; 6] = [ + pub const ALL: [Namespace; 8] = [ Namespace::Identity, Namespace::Session, Namespace::PreKey, Namespace::SignedPreKey, Namespace::SenderKey, Namespace::Device, + Namespace::DecryptBuffer, + Namespace::SentMessage, ]; /// Stable persisted name. @@ -34,6 +41,8 @@ impl Namespace { Namespace::SignedPreKey => "signed_prekey", Namespace::SenderKey => "sender_key", Namespace::Device => "device", + Namespace::DecryptBuffer => "decrypt_buffer", + Namespace::SentMessage => "sent_message", } } } diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 04eddb14..83f4bcf4 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -2,7 +2,8 @@ use crate::record::{Fence, Namespace, RecordStore, WriteOp}; use crate::seal::{SealError, Sealer}; use async_trait::async_trait; use bytes::Bytes; -use std::sync::Arc; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard}; use wacore::store::error::{Result, StoreError}; use wacore::store::traits::SignalStore; @@ -13,12 +14,45 @@ const DEVICE_KEY: &str = "local"; const PREKEY_NOT_UPLOADED: u8 = 0; const PREKEY_UPLOADED: u8 = 1; +/// Namespaces the Signal service reads and writes directly, outside the +/// [`SignalStore`] surface. +const AUX_NAMESPACES: [Namespace; 3] = [ + Namespace::Device, + Namespace::DecryptBuffer, + Namespace::SentMessage, +]; + +/// A write before sealing. +#[derive(Debug, Clone)] +enum PlainOp { + Put(Namespace, String, Vec), + Update(Namespace, String, Vec), + Delete(Namespace, String), +} + +/// An uncommitted change held by a staged store. +#[derive(Debug, Clone)] +enum Staged { + Put(Vec), + /// Applied only if the key still exists when the batch commits. + Update(Vec), + Delete, +} + +type Overlay = BTreeMap<(Namespace, String), Staged>; + /// [`SignalStore`] for one scope, sealed by `S` and fenced by the lease that /// produced `fence`. Build a new one whenever the lease is re-acquired. +/// +/// [`Self::staged`] returns a view whose writes stay in memory until +/// [`Self::commit`] seals them and applies them as one fenced batch, so a +/// decrypt's session update, consumed-prekey delete and buffered result land +/// together or not at all. pub struct RecordSignalStore { records: Arc, sealer: Arc, fence: Fence, + overlay: Option>, } impl RecordSignalStore { @@ -27,6 +61,7 @@ impl RecordSignalStore { records, sealer, fence, + overlay: None, } } @@ -34,6 +69,46 @@ impl RecordSignalStore { &self.fence } + /// A view over the same scope whose writes are held until [`Self::commit`]. + /// Reads see the view's own pending writes first. + pub fn staged(&self) -> Self { + Self { + records: self.records.clone(), + sealer: self.sealer.clone(), + fence: self.fence.clone(), + overlay: Some(Mutex::new(Overlay::new())), + } + } + + /// Seal and apply every pending write of a staged view in one fenced + /// batch. Committing a view that is not staged is a no-op. + pub async fn commit(&self) -> Result<()> { + let pending = match &self.overlay { + Some(overlay) => std::mem::take(&mut *lock(overlay)?), + None => return Ok(()), + }; + let mut ops = Vec::with_capacity(pending.len()); + for ((ns, key), change) in pending { + ops.push(match change { + Staged::Put(plaintext) => WriteOp::Put { + value: self.seal(ns, &key, &plaintext).await?, + ns, + key, + }, + Staged::Update(plaintext) => WriteOp::Update { + value: self.seal(ns, &key, &plaintext).await?, + ns, + key, + }, + Staged::Delete => WriteOp::Delete { ns, key }, + }); + } + if ops.is_empty() { + return Ok(()); + } + self.records.write(&self.fence, &ops).await + } + fn scope(&self) -> &str { &self.fence.scope } @@ -67,7 +142,20 @@ impl RecordSignalStore { .map_err(seal_error) } + /// The staged change for one key, if this view holds one. + fn pending(&self, ns: Namespace, key: &str) -> Result> { + match &self.overlay { + Some(overlay) => Ok(lock(overlay)?.get(&(ns, key.to_owned())).cloned()), + None => Ok(None), + } + } + async fn load(&self, ns: Namespace, key: &str) -> Result>> { + match self.pending(ns, key)? { + Some(Staged::Put(v) | Staged::Update(v)) => return Ok(Some(v)), + Some(Staged::Delete) => return Ok(None), + None => {} + } match self.records.get(self.scope(), ns, key).await? { Some(sealed) => Ok(Some(self.open(ns, key, &sealed).await?)), None => Ok(None), @@ -75,44 +163,155 @@ impl RecordSignalStore { } async fn load_many(&self, ns: Namespace, keys: &[&str]) -> Result)>> { - let found = self.records.get_many(self.scope(), ns, keys).await?; - let mut opened = Vec::with_capacity(found.len()); - for (key, sealed) in found { + let mut opened = Vec::with_capacity(keys.len()); + let mut from_store = Vec::with_capacity(keys.len()); + for key in keys { + match self.pending(ns, key)? { + Some(Staged::Put(v) | Staged::Update(v)) => opened.push(((*key).to_owned(), v)), + Some(Staged::Delete) => {} + None => from_store.push(*key), + } + } + for (key, sealed) in self.records.get_many(self.scope(), ns, &from_store).await? { let plaintext = self.open(ns, &key, &sealed).await?; opened.push((key, plaintext)); } Ok(opened) } - async fn put_op(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result { - Ok(WriteOp::Put { - ns, - key: key.to_owned(), - value: self.seal(ns, key, plaintext).await?, + /// Keys with `prefix`, ascending, including this view's pending changes. + async fn scan(&self, ns: Namespace, prefix: &str, limit: Option) -> Result> { + let Some(overlay) = &self.overlay else { + return self + .records + .scan_keys(self.scope(), ns, prefix, limit) + .await; + }; + // Pending deletes can hide stored keys, so scan without the limit. + let mut keys: std::collections::BTreeSet = self + .records + .scan_keys(self.scope(), ns, prefix, None) + .await? + .into_iter() + .collect(); + for ((pns, key), change) in lock(overlay)?.iter() { + if *pns != ns || !key.starts_with(prefix) { + continue; + } + match change { + Staged::Put(_) => { + keys.insert(key.clone()); + } + Staged::Delete => { + keys.remove(key); + } + Staged::Update(_) => {} + } + } + let keys = keys.into_iter(); + Ok(match limit { + Some(limit) => keys.take(limit).collect(), + None => keys.collect(), }) } - async fn write(&self, ops: &[WriteOp]) -> Result<()> { + async fn apply(&self, ops: Vec) -> Result<()> { if ops.is_empty() { return Ok(()); } - self.records.write(&self.fence, ops).await + if let Some(overlay) = &self.overlay { + let mut overlay = lock(overlay)?; + for op in ops { + let (slot, change) = match op { + PlainOp::Put(ns, key, v) => ((ns, key), Staged::Put(v)), + PlainOp::Update(ns, key, v) => { + // Updating a key this view already wrote keeps it a Put. + let staged = match overlay.get(&(ns, key.clone())) { + Some(Staged::Put(_)) => Staged::Put(v), + Some(Staged::Delete) => continue, + _ => Staged::Update(v), + }; + ((ns, key), staged) + } + PlainOp::Delete(ns, key) => ((ns, key), Staged::Delete), + }; + overlay.insert(slot, change); + } + return Ok(()); + } + let mut sealed = Vec::with_capacity(ops.len()); + for op in ops { + sealed.push(match op { + PlainOp::Put(ns, key, v) => WriteOp::Put { + value: self.seal(ns, &key, &v).await?, + ns, + key, + }, + PlainOp::Update(ns, key, v) => WriteOp::Update { + value: self.seal(ns, &key, &v).await?, + ns, + key, + }, + PlainOp::Delete(ns, key) => WriteOp::Delete { ns, key }, + }); + } + self.records.write(&self.fence, &sealed).await } async fn put_one(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result<()> { - let op = self.put_op(ns, key, plaintext).await?; - self.write(std::slice::from_ref(&op)).await + self.apply(vec![PlainOp::Put(ns, key.to_owned(), plaintext.to_vec())]) + .await } async fn delete_keys>(&self, ns: Namespace, keys: &[K]) -> Result<()> { - let ops: Vec<_> = keys - .iter() - .map(|key| WriteOp::Delete { - ns, - key: key.as_ref().to_owned(), - }) - .collect(); - self.write(&ops).await + self.apply( + keys.iter() + .map(|key| PlainOp::Delete(ns, key.as_ref().to_owned())) + .collect(), + ) + .await + } + + fn check_aux(ns: Namespace) -> Result<()> { + if AUX_NAMESPACES.contains(&ns) { + Ok(()) + } else { + Err(StoreError::Validation(format!( + "{} records are only reachable through SignalStore", + ns.tag() + ))) + } + } + + /// Read a record in one of the service's own namespaces + /// ([`Namespace::Device`], [`Namespace::DecryptBuffer`], + /// [`Namespace::SentMessage`]). + pub async fn load_aux(&self, ns: Namespace, key: &str) -> Result>> { + Self::check_aux(ns)?; + self.load(ns, key).await + } + + /// Write a record in one of the service's own namespaces. + pub async fn put_aux(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result<()> { + Self::check_aux(ns)?; + self.put_one(ns, key, plaintext).await + } + + /// Delete records in one of the service's own namespaces. + pub async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> Result<()> { + Self::check_aux(ns)?; + self.delete_keys(ns, keys).await + } + + /// Keys in one of the service's own namespaces, ascending. + pub async fn scan_aux( + &self, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> Result> { + Self::check_aux(ns)?; + self.scan(ns, prefix, limit).await } /// The local device's sealed key blob, if the scope has been initialised. @@ -126,14 +325,16 @@ impl RecordSignalStore { } async fn any_key_with_prefix(&self, ns: Namespace, prefix: &str) -> Result { - Ok(!self - .records - .scan_keys(self.scope(), ns, prefix, Some(1)) - .await? - .is_empty()) + Ok(!self.scan(ns, prefix, Some(1)).await?.is_empty()) } } +fn lock(overlay: &Mutex) -> Result> { + overlay + .lock() + .map_err(|_| StoreError::Validation("staged record view lock poisoned".into())) +} + fn seal_error(error: SealError) -> StoreError { StoreError::Serialization(Box::new(error)) } @@ -183,11 +384,13 @@ impl SignalStore for RecordSignalStore { } async fn put_identities_batch(&self, identities: &[(Arc, [u8; 32])]) -> Result<()> { - let mut ops = Vec::with_capacity(identities.len()); - for (address, key) in identities { - ops.push(self.put_op(Namespace::Identity, address, key).await?); - } - self.write(&ops).await + self.apply( + identities + .iter() + .map(|(a, k)| PlainOp::Put(Namespace::Identity, a.to_string(), k.to_vec())) + .collect(), + ) + .await } async fn load_identity(&self, address: &str) -> Result> { @@ -217,11 +420,13 @@ impl SignalStore for RecordSignalStore { } async fn put_sessions_batch(&self, sessions: &[(Arc, Bytes)]) -> Result<()> { - let mut ops = Vec::with_capacity(sessions.len()); - for (address, session) in sessions { - ops.push(self.put_op(Namespace::Session, address, session).await?); - } - self.write(&ops).await + self.apply( + sessions + .iter() + .map(|(a, s)| PlainOp::Put(Namespace::Session, a.to_string(), s.to_vec())) + .collect(), + ) + .await } async fn get_sessions_batch(&self, addresses: &[Arc]) -> Result, Bytes)>> { @@ -250,11 +455,15 @@ impl SignalStore for RecordSignalStore { } async fn has_session(&self, address: &str) -> Result { - Ok(self - .records - .get(self.scope(), Namespace::Session, address) - .await? - .is_some()) + match self.pending(Namespace::Session, address)? { + Some(Staged::Delete) => Ok(false), + Some(_) => Ok(true), + None => Ok(self + .records + .get(self.scope(), Namespace::Session, address) + .await? + .is_some()), + } } async fn has_signal_state_for_user(&self, user: &str) -> Result { @@ -272,21 +481,27 @@ impl SignalStore for RecordSignalStore { } async fn store_prekey(&self, id: u32, record: &[u8], uploaded: bool) -> Result<()> { - let key = id_key(id); - self.put_one(Namespace::PreKey, &key, &prekey_plaintext(record, uploaded)) - .await + self.put_one( + Namespace::PreKey, + &id_key(id), + &prekey_plaintext(record, uploaded), + ) + .await } async fn store_prekeys_batch(&self, keys: &[(u32, Bytes)], uploaded: bool) -> Result<()> { - let mut ops = Vec::with_capacity(keys.len()); - for (id, record) in keys { - let key = id_key(*id); - ops.push( - self.put_op(Namespace::PreKey, &key, &prekey_plaintext(record, uploaded)) - .await?, - ); - } - self.write(&ops).await + self.apply( + keys.iter() + .map(|(id, r)| { + PlainOp::Put( + Namespace::PreKey, + id_key(*id), + prekey_plaintext(r, uploaded), + ) + }) + .collect(), + ) + .await } async fn load_prekey(&self, id: u32) -> Result> { @@ -312,18 +527,15 @@ impl SignalStore for RecordSignalStore { let mut ops = Vec::with_capacity(ids.len()); for (key, plaintext) in self.load_many(Namespace::PreKey, &key_refs).await? { let record = prekey_record(plaintext)?; - let value = self - .seal(Namespace::PreKey, &key, &prekey_plaintext(&record, true)) - .await?; // Update, not Put: a key consumed after the read above stays // deleted instead of being resurrected by this write. - ops.push(WriteOp::Update { - ns: Namespace::PreKey, + ops.push(PlainOp::Update( + Namespace::PreKey, key, - value, - }); + prekey_plaintext(&record, true), + )); } - self.write(&ops).await + self.apply(ops).await } async fn remove_prekey(&self, id: u32) -> Result<()> { @@ -336,10 +548,7 @@ impl SignalStore for RecordSignalStore { } async fn get_max_prekey_id(&self) -> Result { - let keys = self - .records - .scan_keys(self.scope(), Namespace::PreKey, "", None) - .await?; + let keys = self.scan(Namespace::PreKey, "", None).await?; keys.last().map_or(Ok(0), |key| parse_id(key)) } @@ -353,10 +562,7 @@ impl SignalStore for RecordSignalStore { } async fn load_all_signed_prekeys(&self) -> Result)>> { - let keys = self - .records - .scan_keys(self.scope(), Namespace::SignedPreKey, "", None) - .await?; + let keys = self.scan(Namespace::SignedPreKey, "", None).await?; let key_refs: Vec<&str> = keys.iter().map(String::as_str).collect(); let mut result = Vec::with_capacity(keys.len()); for (key, plaintext) in self.load_many(Namespace::SignedPreKey, &key_refs).await? { @@ -376,11 +582,13 @@ impl SignalStore for RecordSignalStore { } async fn put_sender_keys_batch(&self, sender_keys: &[(Arc, Bytes)]) -> Result<()> { - let mut ops = Vec::with_capacity(sender_keys.len()); - for (address, record) in sender_keys { - ops.push(self.put_op(Namespace::SenderKey, address, record).await?); - } - self.write(&ops).await + self.apply( + sender_keys + .iter() + .map(|(a, r)| PlainOp::Put(Namespace::SenderKey, a.to_string(), r.to_vec())) + .collect(), + ) + .await } async fn get_sender_key(&self, address: &str) -> Result>> { @@ -437,6 +645,87 @@ mod tests { assert!(!raw.windows(16).any(|w| w == b"identity-private")); } + #[test] + fn staged_writes_stay_private_until_commit() { + let (records, store) = setup(); + block_on(store.put_session("keep@s.whatsapp.net", b"old")).unwrap(); + block_on(store.store_prekey(5, b"five", false)).unwrap(); + + let staged = store.staged(); + block_on(staged.put_session("keep@s.whatsapp.net", b"new")).unwrap(); + block_on(staged.put_session("fresh@s.whatsapp.net", b"s")).unwrap(); + block_on(staged.remove_prekey(5)).unwrap(); + block_on(staged.put_aux(Namespace::DecryptBuffer, "h1", b"result")).unwrap(); + + // The view reads its own writes. + assert_eq!( + block_on(staged.get_session("keep@s.whatsapp.net")) + .unwrap() + .as_deref(), + Some(&b"new"[..]) + ); + assert_eq!(block_on(staged.load_prekey(5)).unwrap(), None); + assert_eq!(block_on(staged.get_max_prekey_id()).unwrap(), 0); + assert!(block_on(staged.has_signal_state_for_user("fresh")).unwrap()); + + // Nothing reached the store yet. + assert_eq!( + block_on(store.get_session("keep@s.whatsapp.net")) + .unwrap() + .as_deref(), + Some(&b"old"[..]) + ); + assert!( + block_on(records.get("device-1", Namespace::DecryptBuffer, "h1")) + .unwrap() + .is_none() + ); + + block_on(staged.commit()).unwrap(); + assert_eq!( + block_on(store.get_session("keep@s.whatsapp.net")) + .unwrap() + .as_deref(), + Some(&b"new"[..]) + ); + assert_eq!(block_on(store.load_prekey(5)).unwrap(), None); + assert_eq!( + block_on(store.load_aux(Namespace::DecryptBuffer, "h1")) + .unwrap() + .as_deref(), + Some(&b"result"[..]) + ); + } + + #[test] + fn a_stale_staged_commit_applies_nothing() { + let (records, store) = setup(); + let staged = store.staged(); + block_on(staged.put_session("a@s.whatsapp.net", b"s")).unwrap(); + block_on(records.acquire("device-1", "node-b", TTL, TTL)) + .unwrap() + .unwrap(); + let err = block_on(staged.commit()).unwrap_err(); + assert!(is_fence_lost(&err)); + assert!( + block_on(records.get("device-1", Namespace::Session, "a@s.whatsapp.net")) + .unwrap() + .is_none() + ); + } + + #[test] + fn signal_namespaces_are_not_reachable_through_the_aux_api() { + let (_, store) = setup(); + assert!(block_on(store.put_aux(Namespace::Session, "x", b"v")).is_err()); + assert!(block_on(store.load_aux(Namespace::Identity, "x")).is_err()); + block_on(store.put_aux(Namespace::SentMessage, "chat\0id", b"m")).unwrap(); + assert_eq!( + block_on(store.scan_aux(Namespace::SentMessage, "chat", None)).unwrap(), + vec!["chat\0id".to_owned()] + ); + } + #[test] fn values_are_sealed_at_rest() { let (records, store) = setup(); diff --git a/wacore/signal-ops/Cargo.toml b/wacore/signal-ops/Cargo.toml index 8c78eff4..8c4946e0 100644 --- a/wacore/signal-ops/Cargo.toml +++ b/wacore/signal-ops/Cargo.toml @@ -13,7 +13,9 @@ publish = false async-lock = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } +hex = { workspace = true } rand = { workspace = true } +sha2 = { workspace = true } thiserror = { workspace = true } wacore = { workspace = true } wacore-recordstore = { path = "../recordstore" } diff --git a/wacore/signal-ops/src/inbound.rs b/wacore/signal-ops/src/inbound.rs new file mode 100644 index 00000000..e8cc8ed4 --- /dev/null +++ b/wacore/signal-ops/src/inbound.rs @@ -0,0 +1,316 @@ +//! Buffered, idempotent receive for the Signal service. +//! +//! A decrypt advances the ratchet, so the same ciphertext cannot be decrypted +//! twice. Each result is kept in the sealed decrypt buffer, written in the +//! same batch as the ratchet advance, until the app confirms delivery with +//! [`SignalOps::mark_delivered`]. A redelivered ciphertext returns the +//! buffered result; once delivered it returns [`Received::AlreadyDelivered`]. + +use crate::error::OpsError; +use crate::ops::{EncKind, ServiceStore, SignalOps}; +use sha2::{Digest, Sha256}; +use std::sync::Arc; +use wacore::libsignal::protocol::ProtocolAddress; +use wacore::message_processing::{DecryptedMessageResult, process_decrypted_plaintext}; +use wacore_recordstore::Namespace; +use waproto::whatsapp as wa; + +const STATE_PENDING: u8 = 1; +const STATE_DELIVERED: u8 = 2; +const HEADER_LEN: usize = 1 + 1 + 1 + 8; + +/// The `` of a received payload. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReceiveKind { + /// `pkmsg` + PreKey, + /// `msg` + Message, + /// `skmsg`: group sender-key message for the request's chat. + SenderKey, +} + +impl ReceiveKind { + fn wire(self) -> &'static str { + match self { + ReceiveKind::PreKey => "pkmsg", + ReceiveKind::Message => "msg", + ReceiveKind::SenderKey => "skmsg", + } + } +} + +/// One `` payload from a `` stanza. +#[derive(Debug, Clone, Copy)] +pub struct ReceiveRequest<'a> { + /// The stanza's chat JID. For group traffic this is the group. + pub chat: &'a str, + /// The sending device's Signal address. + pub sender: &'a ProtocolAddress, + pub kind: ReceiveKind, + pub ciphertext: &'a [u8], + /// The `` padding version. + pub padding_version: u8, + /// The sender is one of this account's own devices. + pub is_from_me: bool, +} + +#[derive(Debug)] +pub enum Received { + Message(Box), + /// Delivered before; the client only needs to send its receipt again. + AlreadyDelivered { + receipt_key: String, + }, +} + +#[derive(Debug)] +pub struct ReceivedMessage { + /// Pass to [`SignalOps::mark_delivered`] once the app has the message. + pub receipt_key: String, + /// Unpadded content with own-device copies unwrapped, plus its sender-key + /// distribution and protocol parts. + pub content: DecryptedMessageResult, + pub identity_changed: bool, + /// Served from the buffer after an earlier decrypt was not acknowledged. + pub redelivered: bool, + /// An app-state key share, only when sent by one of this account's own + /// devices (WA Web's `isMeAccount` gate). The one piece of content the + /// Polymorfa client receives. + pub app_state_key_share: Option, +} + +fn receipt_key(request: &ReceiveRequest<'_>) -> String { + let mut hasher = Sha256::new(); + for part in [ + request.kind.wire().as_bytes(), + request.chat.as_bytes(), + request.sender.as_str().as_bytes(), + ] { + hasher.update(part); + hasher.update([0]); + } + hasher.update(request.ciphertext); + hex::encode(hasher.finalize()) +} + +fn now_ms() -> u64 { + u64::try_from(wacore::time::now_utc().timestamp_millis()).unwrap_or(0) +} + +struct BufferEntry { + state: u8, + padding_version: u8, + identity_changed: bool, + inserted_ms: u64, + padded_plaintext: Vec, +} + +impl BufferEntry { + fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(HEADER_LEN + self.padded_plaintext.len()); + out.push(self.state); + out.push(self.padding_version); + out.push(u8::from(self.identity_changed)); + out.extend_from_slice(&self.inserted_ms.to_be_bytes()); + out.extend_from_slice(&self.padded_plaintext); + out + } + + fn decode(bytes: Vec) -> Result { + if bytes.len() < HEADER_LEN { + return Err(OpsError::InvalidInput( + "decrypt buffer entry is truncated".into(), + )); + } + let mut inserted = [0u8; 8]; + inserted.copy_from_slice(&bytes[3..HEADER_LEN]); + Ok(Self { + state: bytes[0], + padding_version: bytes[1], + identity_changed: bytes[2] != 0, + inserted_ms: u64::from_be_bytes(inserted), + padded_plaintext: bytes[HEADER_LEN..].to_vec(), + }) + } + + fn delivered(inserted_ms: u64) -> Self { + Self { + state: STATE_DELIVERED, + padding_version: 0, + identity_changed: false, + inserted_ms, + padded_plaintext: Vec::new(), + } + } +} + +fn classify( + padded: &[u8], + padding_version: u8, + is_from_me: bool, +) -> Result { + process_decrypted_plaintext(padded, padding_version, is_from_me) + .map_err(|e| OpsError::InvalidInput(format!("plaintext did not decode: {e}"))) +} + +fn key_share( + content: &DecryptedMessageResult, + is_from_me: bool, +) -> Option { + if !is_from_me { + return None; + } + content + .protocol_message + .as_ref() + .and_then(|p| p.app_state_sync_key_share.clone()) +} + +impl SignalOps { + /// Decrypt, buffer and classify one received payload. Sender-key + /// distributions inside it are stored in the same batch as the decrypt. + pub async fn receive(&self, request: ReceiveRequest<'_>) -> Result { + let receipt_key = receipt_key(&request); + // Pairwise traffic shares the sender's session lock; group traffic + // serialises per sender chain. + let lock_key = match request.kind { + ReceiveKind::SenderKey => format!("{}\u{0}{}", request.chat, request.sender.as_str()), + _ => request.sender.as_str().to_owned(), + }; + let lock = self.address_lock(&lock_key); + let _guard = lock.lock().await; + + if let Some(bytes) = self + .store() + .load_aux(Namespace::DecryptBuffer, &receipt_key) + .await? + { + let entry = BufferEntry::decode(bytes)?; + if entry.state == STATE_DELIVERED { + return Ok(Received::AlreadyDelivered { receipt_key }); + } + let content = classify( + &entry.padded_plaintext, + entry.padding_version, + request.is_from_me, + )?; + return Ok(Received::Message(Box::new(ReceivedMessage { + app_state_key_share: key_share(&content, request.is_from_me), + receipt_key, + content, + identity_changed: entry.identity_changed, + redelivered: true, + }))); + } + + let staged = Arc::new(self.store().staged()); + let (padded, identity_changed) = match request.kind { + ReceiveKind::PreKey | ReceiveKind::Message => { + let kind = if request.kind == ReceiveKind::PreKey { + EncKind::PreKey + } else { + EncKind::Message + }; + let decrypted = self + .decrypt_into(&staged, request.sender, kind, request.ciphertext) + .await?; + (decrypted.plaintext, decrypted.identity_changed) + } + ReceiveKind::SenderKey => ( + self.group_decrypt_into(&staged, request.chat, request.sender, request.ciphertext) + .await?, + false, + ), + }; + + let inserted_ms = now_ms(); + let content = match classify(&padded, request.padding_version, request.is_from_me) { + Ok(content) => content, + Err(error) => { + // The ratchet already advanced; keep that, and record the + // ciphertext as handled so redelivery does not loop. + staged + .put_aux( + Namespace::DecryptBuffer, + &receipt_key, + &BufferEntry::delivered(inserted_ms).encode(), + ) + .await?; + staged.commit().await?; + return Err(error); + } + }; + + if let Some(skdm) = &content.skdm + && let Some(axolotl) = &skdm.axolotl_sender_key_distribution_message + { + self.process_sender_key_distribution_into( + &staged, + request.chat, + request.sender, + axolotl, + ) + .await?; + } + + let entry = BufferEntry { + state: STATE_PENDING, + padding_version: request.padding_version, + identity_changed, + inserted_ms, + padded_plaintext: padded, + }; + staged + .put_aux(Namespace::DecryptBuffer, &receipt_key, &entry.encode()) + .await?; + staged.commit().await?; + + Ok(Received::Message(Box::new(ReceivedMessage { + app_state_key_share: key_share(&content, request.is_from_me), + receipt_key, + content, + identity_changed, + redelivered: false, + }))) + } + + /// The app has the message: drop its plaintext and keep a marker so a + /// redelivered ciphertext is recognised. + pub async fn mark_delivered(&self, receipt_key: &str) -> Result<(), OpsError> { + let store = self.store(); + if let Some(bytes) = store + .load_aux(Namespace::DecryptBuffer, receipt_key) + .await? + { + let entry = BufferEntry::decode(bytes)?; + if entry.state != STATE_DELIVERED { + store + .put_aux( + Namespace::DecryptBuffer, + receipt_key, + &BufferEntry::delivered(entry.inserted_ms).encode(), + ) + .await?; + } + } + Ok(()) + } + + /// Delete buffer entries inserted before `cutoff_ms`, delivered or not. + /// Returns how many were removed. + pub async fn prune_decrypt_buffer(&self, cutoff_ms: u64) -> Result { + let store = self.store(); + let mut expired = Vec::new(); + for key in store.scan_aux(Namespace::DecryptBuffer, "").await? { + if let Some(bytes) = store.load_aux(Namespace::DecryptBuffer, &key).await? + && BufferEntry::decode(bytes)?.inserted_ms < cutoff_ms + { + expired.push(key); + } + } + let refs: Vec<&str> = expired.iter().map(String::as_str).collect(); + store.delete_aux(Namespace::DecryptBuffer, &refs).await?; + Ok(expired.len()) + } +} diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index a9925d3a..8487983d 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -13,11 +13,13 @@ mod device; mod error; +mod inbound; mod ops; mod stores; pub use device::{DeviceKeys, PublicIdentity, PublicPreKey, PublicSignedPreKey}; pub use error::OpsError; +pub use inbound::{ReceiveKind, ReceiveRequest, Received, ReceivedMessage}; pub use ops::{ - Decrypted, DeviceKeyStore, EncKind, Encrypted, PairingSignature, RemoteBundle, SignalOps, + Decrypted, EncKind, Encrypted, PairingSignature, RemoteBundle, ServiceStore, SignalOps, }; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 05b00fb1..9b898b9c 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -19,19 +19,29 @@ use wacore::libsignal::store::sender_key_name::SenderKeyName; use wacore::pair::PairUtils; use wacore::store::error::Result as StoreResult; use wacore::store::traits::SignalStore; -use wacore_recordstore::{RecordSignalStore, RecordStore, Sealer}; +use wacore_recordstore::{Namespace, RecordSignalStore, RecordStore, Sealer}; -/// Persistence for the local device's key blob ([`DeviceKeys::encode`]). +/// Storage the Signal service needs beyond [`SignalStore`]: the device key +/// blob, its own records (decrypt buffer, sent messages), and staged views +/// whose writes commit as one batch. #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] -pub trait DeviceKeyStore: Send + Sync { +pub trait ServiceStore: SignalStore + Sized + 'static { async fn load_device_keys(&self) -> StoreResult>>; async fn save_device_keys(&self, blob: &[u8]) -> StoreResult<()>; + /// A view whose writes are held until [`Self::commit`]. + fn staged(&self) -> Self; + /// Apply a staged view's writes as one fenced batch. + async fn commit(&self) -> StoreResult<()>; + async fn load_aux(&self, ns: Namespace, key: &str) -> StoreResult>>; + async fn put_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()>; + async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> StoreResult<()>; + async fn scan_aux(&self, ns: Namespace, prefix: &str) -> StoreResult>; } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] #[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl DeviceKeyStore for RecordSignalStore { +impl ServiceStore for RecordSignalStore { async fn load_device_keys(&self) -> StoreResult>> { self.load_device().await } @@ -39,6 +49,30 @@ impl DeviceKeyStore for RecordSignalStore { async fn save_device_keys(&self, blob: &[u8]) -> StoreResult<()> { self.save_device(blob).await } + + fn staged(&self) -> Self { + RecordSignalStore::staged(self) + } + + async fn commit(&self) -> StoreResult<()> { + RecordSignalStore::commit(self).await + } + + async fn load_aux(&self, ns: Namespace, key: &str) -> StoreResult>> { + RecordSignalStore::load_aux(self, ns, key).await + } + + async fn put_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()> { + RecordSignalStore::put_aux(self, ns, key, value).await + } + + async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> StoreResult<()> { + RecordSignalStore::delete_aux(self, ns, keys).await + } + + async fn scan_aux(&self, ns: Namespace, prefix: &str) -> StoreResult> { + RecordSignalStore::scan_aux(self, ns, prefix, None).await + } } /// Pairwise ciphertext kinds, named as in the `` attribute. @@ -115,7 +149,7 @@ fn public_key(bytes: &[u8; 32], what: &str) -> Result { .map_err(|_| OpsError::InvalidInput(format!("{what} is not a valid public key"))) } -impl SignalOps { +impl SignalOps { /// Open a scope that already has device keys. pub async fn open(store: Arc) -> Result { let blob = store @@ -149,13 +183,25 @@ impl SignalOps { } async fn stores(&self) -> Stores { + self.stores_over(self.store.clone()).await + } + + pub(crate) async fn stores_over(&self, store: Arc) -> Stores { Stores { - store: self.store.clone(), + store, keys: self.keys.read().await.clone(), sender_key_locks: self.sender_key_locks.clone(), } } + pub(crate) fn store(&self) -> &Arc { + &self.store + } + + pub(crate) fn address_lock(&self, key: &str) -> Arc> { + self.address_locks.get(key) + } + async fn replace_keys(&self, keys: DeviceKeys) -> Result<(), OpsError> { self.store.save_device_keys(&keys.encode()).await?; *self.keys.write().await = Arc::new(keys); @@ -282,7 +328,8 @@ impl SignalOps { Ok(()) } - /// Decrypt one pairwise `` payload. + /// Decrypt one pairwise `` payload. The session update and the + /// consumed prekey's removal commit together. pub async fn decrypt( &self, sender: &ProtocolAddress, @@ -291,7 +338,22 @@ impl SignalOps { ) -> Result { let lock = self.address_locks.get(sender.as_str()); let _guard = lock.lock().await; - let mut sessions = self.stores().await; + let staged = Arc::new(self.store.staged()); + let decrypted = self.decrypt_into(&staged, sender, kind, ciphertext).await?; + staged.commit().await?; + Ok(decrypted) + } + + /// Decrypt into a staged view without committing it. The caller holds the + /// address lock and commits. + pub(crate) async fn decrypt_into( + &self, + staged: &Arc, + sender: &ProtocolAddress, + kind: EncKind, + ciphertext: &[u8], + ) -> Result { + let mut sessions = self.stores_over(staged.clone()).await; let mut identities = sessions.clone(); let result = match kind { EncKind::PreKey => { @@ -316,10 +378,10 @@ impl SignalOps { .await? } }; - // The promoted session is already written; only now is it safe to - // delete the one-time prekey it consumed. + // Staged with the promoted session, so the prekey is never gone while + // the session that consumed it is not durable. if let Some(id) = result.consumed_prekey_id { - self.store.remove_prekey(id.into()).await?; + staged.remove_prekey(id.into()).await?; } Ok(Decrypted { plaintext: result.plaintext, @@ -396,6 +458,34 @@ impl SignalOps { Ok(()) } + /// Decrypt a group `skmsg` into a staged view without committing it. + pub(crate) async fn group_decrypt_into( + &self, + staged: &Arc, + group: &str, + sender: &ProtocolAddress, + ciphertext: &[u8], + ) -> Result, OpsError> { + let name = SenderKeyName::from_parts(group, sender.as_str()); + let mut store = self.stores_over(staged.clone()).await; + Ok(group_decrypt(ciphertext, &mut store, &name).await?) + } + + /// Store a received sender key in a staged view without committing it. + pub(crate) async fn process_sender_key_distribution_into( + &self, + staged: &Arc, + group: &str, + sender: &ProtocolAddress, + skdm: &[u8], + ) -> Result<(), OpsError> { + let name = SenderKeyName::from_parts(group, sender.as_str()); + let message = SenderKeyDistributionMessage::try_from(skdm)?; + let mut store = self.stores_over(staged.clone()).await; + process_sender_key_distribution_message(&name, &message, &mut store).await?; + Ok(()) + } + /// Decrypt a group `skmsg` from `sender`. pub async fn group_decrypt( &self, diff --git a/wacore/signal-ops/tests/receive.rs b/wacore/signal-ops/tests/receive.rs new file mode 100644 index 00000000..734e4ac4 --- /dev/null +++ b/wacore/signal-ops/tests/receive.rs @@ -0,0 +1,296 @@ +//! Buffered receive: idempotent redelivery, atomic commits, sender keys and +//! the app-state key share gate, with real padded WhatsApp messages. + +use futures::executor::block_on; +use std::sync::Arc; +use wacore::libsignal::protocol::{DeviceId, ProtocolAddress}; +use wacore::messages::MessageUtils; +use wacore_recordstore::{AesGcmSealer, LeaseStore, MemoryRecordStore, RecordSignalStore}; +use wacore_signal_ops::{ + Encrypted, ReceiveKind, ReceiveRequest, Received, ReceivedMessage, RemoteBundle, SignalOps, +}; +use waproto::whatsapp as wa; + +type Store = RecordSignalStore; + +const TTL: u64 = 60_000; +const GROUP: &str = "120363000000001@g.us"; + +struct Device { + records: Arc, + scope: &'static str, + ops: SignalOps, +} + +fn store_for(records: &Arc, scope: &str, holder: &str, now: u64) -> Arc { + let lease = block_on(records.acquire(scope, holder, now, TTL)) + .unwrap() + .expect("lease available"); + Arc::new(RecordSignalStore::new( + records.clone(), + Arc::new(AesGcmSealer::new(&[9; 32])), + lease.fence, + )) +} + +fn device(scope: &'static str) -> Device { + let records = Arc::new(MemoryRecordStore::new()); + let ops = block_on(SignalOps::create(store_for(&records, scope, "node-a", 0))).unwrap(); + Device { + records, + scope, + ops, + } +} + +fn address(user: &str) -> ProtocolAddress { + ProtocolAddress::new(&format!("{user}@s.whatsapp.net"), DeviceId::from(1)) +} + +fn connect(from: &Device, to: &Device, to_addr: &ProtocolAddress) { + let identity = block_on(to.ops.public_identity()); + let prekey = block_on(to.ops.generate_prekeys(1)).unwrap().remove(0); + block_on(from.ops.establish_session( + to_addr, + &RemoteBundle { + registration_id: identity.registration_id, + device_id: 1, + identity_key: identity.identity_key, + signed_prekey_id: identity.signed_prekey.id, + signed_prekey: identity.signed_prekey.public_key, + signed_prekey_signature: identity.signed_prekey.signature, + prekey: Some((prekey.id, prekey.public_key)), + }, + )) + .unwrap(); +} + +// Test-only proto encoding; the repo allows it in tests. +#[allow(clippy::disallowed_methods)] +fn padded(message: &wa::Message) -> Vec { + MessageUtils::pad_message_v2(waproto::codec::message_to_vec(message)) +} + +fn text(body: &str) -> wa::Message { + wa::Message { + conversation: Some(body.to_owned()), + ..Default::default() + } +} + +fn kind(encrypted: &Encrypted) -> ReceiveKind { + match encrypted.kind { + wacore_signal_ops::EncKind::PreKey => ReceiveKind::PreKey, + wacore_signal_ops::EncKind::Message => ReceiveKind::Message, + } +} + +fn request<'a>( + chat: &'a str, + sender: &'a ProtocolAddress, + kind: ReceiveKind, + ciphertext: &'a [u8], + is_from_me: bool, +) -> ReceiveRequest<'a> { + ReceiveRequest { + chat, + sender, + kind, + ciphertext, + padding_version: 2, + is_from_me, + } +} + +fn message(received: Received) -> ReceivedMessage { + match received { + Received::Message(message) => *message, + Received::AlreadyDelivered { .. } => panic!("expected a message"), + } +} + +#[test] +fn redelivery_returns_the_buffered_result_until_delivered() { + let alice = device("alice-redeliver"); + let bob = device("bob-redeliver"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + + let sent = block_on(alice.ops.encrypt(&bob_addr, &padded(&text("hello")))).unwrap(); + let chat = alice_addr.name().to_owned(); + let req = request(&chat, &alice_addr, kind(&sent), &sent.ciphertext, false); + + let first = message(block_on(bob.ops.receive(req)).unwrap()); + assert_eq!(first.content.message.conversation.as_deref(), Some("hello")); + assert!(!first.redelivered); + + let again = message(block_on(bob.ops.receive(req)).unwrap()); + assert!(again.redelivered); + assert_eq!(again.receipt_key, first.receipt_key); + assert_eq!(again.content.message.conversation.as_deref(), Some("hello")); + + block_on(bob.ops.mark_delivered(&first.receipt_key)).unwrap(); + assert!(matches!( + block_on(bob.ops.receive(req)).unwrap(), + Received::AlreadyDelivered { .. } + )); +} + +#[test] +fn a_fenced_out_receive_leaves_the_ciphertext_decryptable_by_the_successor() { + let alice = device("alice-atomic"); + let bob = device("bob-atomic"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + let chat = alice_addr.name().to_owned(); + + let first = block_on(alice.ops.encrypt(&bob_addr, &padded(&text("one")))).unwrap(); + block_on(bob.ops.receive(request( + &chat, + &alice_addr, + kind(&first), + &first.ciphertext, + false, + ))) + .unwrap(); + + let second = block_on(alice.ops.encrypt(&bob_addr, &padded(&text("two")))).unwrap(); + let successor = store_for(&bob.records, bob.scope, "node-b", TTL + 1); + let stale = block_on(bob.ops.receive(request( + &chat, + &alice_addr, + kind(&second), + &second.ciphertext, + false, + ))); + assert!(stale.expect_err("fenced out").is_fence_lost()); + + // The stale node's ratchet advance never committed, so nothing was lost. + let bob_b = block_on(SignalOps::open(successor)).unwrap(); + let got = message( + block_on(bob_b.receive(request( + &chat, + &alice_addr, + kind(&second), + &second.ciphertext, + false, + ))) + .unwrap(), + ); + assert_eq!(got.content.message.conversation.as_deref(), Some("two")); +} + +#[test] +fn a_sender_key_carried_in_a_pairwise_message_unlocks_group_traffic() { + let alice = device("alice-skdm"); + let bob = device("bob-skdm"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + + let skdm = block_on(alice.ops.sender_key_distribution(GROUP, &alice_addr)).unwrap(); + let carrier = wa::Message { + sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { + group_id: Some(GROUP.to_owned()), + axolotl_sender_key_distribution_message: Some(skdm), + }) + .into(), + ..Default::default() + }; + let sent = block_on(alice.ops.encrypt(&bob_addr, &padded(&carrier))).unwrap(); + let received = message( + block_on(bob.ops.receive(request( + GROUP, + &alice_addr, + kind(&sent), + &sent.ciphertext, + false, + ))) + .unwrap(), + ); + assert!(received.content.is_skdm_only); + + let skmsg = block_on( + alice + .ops + .group_encrypt(GROUP, &alice_addr, &padded(&text("hi group"))), + ) + .unwrap(); + let group = message( + block_on(bob.ops.receive(request( + GROUP, + &alice_addr, + ReceiveKind::SenderKey, + &skmsg, + false, + ))) + .unwrap(), + ); + assert_eq!( + group.content.message.conversation.as_deref(), + Some("hi group") + ); +} + +#[test] +fn only_own_devices_can_hand_over_app_state_keys() { + let alice = device("alice-keys"); + let bob = device("bob-keys"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + let share = wa::Message { + protocol_message: Some(wa::message::ProtocolMessage { + app_state_sync_key_share: Some(wa::message::AppStateSyncKeyShare::default()).into(), + ..Default::default() + }) + .into(), + ..Default::default() + }; + let chat = alice_addr.name().to_owned(); + + let from_peer = block_on(alice.ops.encrypt(&bob_addr, &padded(&share))).unwrap(); + let peer = message( + block_on(bob.ops.receive(request( + &chat, + &alice_addr, + kind(&from_peer), + &from_peer.ciphertext, + false, + ))) + .unwrap(), + ); + assert!(peer.app_state_key_share.is_none()); + + let from_self = block_on(alice.ops.encrypt(&bob_addr, &padded(&share))).unwrap(); + let own = message( + block_on(bob.ops.receive(request( + &chat, + &alice_addr, + kind(&from_self), + &from_self.ciphertext, + true, + ))) + .unwrap(), + ); + assert!(own.app_state_key_share.is_some()); +} + +#[test] +fn pruning_drops_old_buffer_entries() { + let alice = device("alice-prune"); + let bob = device("bob-prune"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + let chat = alice_addr.name().to_owned(); + let sent = block_on(alice.ops.encrypt(&bob_addr, &padded(&text("old")))).unwrap(); + block_on(bob.ops.receive(request( + &chat, + &alice_addr, + kind(&sent), + &sent.ciphertext, + false, + ))) + .unwrap(); + + assert_eq!(block_on(bob.ops.prune_decrypt_buffer(0)).unwrap(), 0); + assert_eq!(block_on(bob.ops.prune_decrypt_buffer(u64::MAX)).unwrap(), 1); +} diff --git a/wacore/signal-wasm/Cargo.toml b/wacore/signal-wasm/Cargo.toml index 2d6e2083..241e3811 100644 --- a/wacore/signal-wasm/Cargo.toml +++ b/wacore/signal-wasm/Cargo.toml @@ -23,6 +23,7 @@ wacore-recordstore = { path = "../recordstore" } wacore-signal-ops = { path = "../signal-ops" } wasm-bindgen = "0.2.128" wasm-bindgen-futures = "0.4" +waproto = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] getrandom = { workspace = true, features = ["wasm_js"] } diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index 6b59773b..259ee0f9 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -18,7 +18,7 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use wacore_recordstore::{Lease, LeaseStore, RecordSignalStore}; -use wacore_signal_ops::{EncKind, OpsError, SignalOps}; +use wacore_signal_ops::{EncKind, OpsError, ReceiveKind, ReceiveRequest, Received, SignalOps}; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::future_to_promise; @@ -358,6 +358,108 @@ impl SignalDevice { }) } + /// Decrypt, buffer and classify one received payload. Resolves + /// `{ status: "message", receiptKey, message, isSkdmOnly, identityChanged, + /// redelivered }` where `message` is the encoded, unpadded `waE2E.Message`, + /// or `{ status: "already_delivered", receiptKey }`. Call `markDelivered` + /// once the app has stored the message. + #[allow(clippy::too_many_arguments)] + pub fn receive( + &self, + chat: String, + user: String, + device: u32, + kind: String, + ciphertext: Vec, + padding_version: u8, + is_from_me: bool, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let kind = match kind.as_str() { + "pkmsg" => ReceiveKind::PreKey, + "msg" => ReceiveKind::Message, + "skmsg" => ReceiveKind::SenderKey, + other => { + return Err(convert::coded_error( + "invalid_input", + &format!("unsupported enc type {other}"), + )); + } + }; + let sender = address(&user, device); + let received = inner + .ops + .receive(ReceiveRequest { + chat: &chat, + sender: &sender, + kind, + ciphertext: &ciphertext, + padding_version, + is_from_me, + }) + .await + .map_err(ops_error)?; + let out = Object::new(); + match received { + Received::AlreadyDelivered { receipt_key } => { + set(&out, "status", &JsValue::from_str("already_delivered")); + set(&out, "receiptKey", &JsValue::from_str(&receipt_key)); + } + Received::Message(message) => { + set(&out, "status", &JsValue::from_str("message")); + set(&out, "receiptKey", &JsValue::from_str(&message.receipt_key)); + let encoded = waproto::codec::message_to_vec(&message.content.message); + set(&out, "message", &Uint8Array::from(encoded.as_slice())); + set( + &out, + "isSkdmOnly", + &JsValue::from_bool(message.content.is_skdm_only), + ); + set( + &out, + "identityChanged", + &JsValue::from_bool(message.identity_changed), + ); + set( + &out, + "redelivered", + &JsValue::from_bool(message.redelivered), + ); + } + } + Ok(out.into()) + }) + } + + /// Drop a delivered message's plaintext from the decrypt buffer. + #[wasm_bindgen(js_name = markDelivered)] + pub fn mark_delivered(&self, receipt_key: String) -> Promise { + let inner = self.inner.clone(); + promise(async move { + inner + .ops + .mark_delivered(&receipt_key) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + /// Delete decrypt-buffer entries older than `cutoffMs`; resolves the count. + #[wasm_bindgen(js_name = pruneDecryptBuffer)] + pub fn prune_decrypt_buffer(&self, cutoff_ms: f64) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let removed = inner + .ops + .prune_decrypt_buffer(cutoff_ms as u64) + .await + .map_err(ops_error)?; + Ok(JsValue::from_f64(removed as f64)) + }) + } + /// Verify the primary's pair-success container and add the device /// signature. Rejects with code "pairing_refused" and a `status`. #[wasm_bindgen(js_name = signPairing)] diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index 14451dac..3f405bf3 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -237,3 +237,43 @@ test("pairing and input errors carry codes", async () => { (error) => error.code === "invalid_input", ); }); + +// A `waE2E.Message` with only `conversation` (field 1), padded as WhatsApp +// pads (n bytes of value n, 1..=16). +function paddedConversation(body) { + const utf8 = text(body); + const pad = 1 + (utf8.length % 16); + const out = new Uint8Array(2 + utf8.length + pad); + out[0] = 0x0a; + out[1] = utf8.length; + out.set(utf8, 2); + out.fill(pad, 2 + utf8.length); + return out; +} + +function conversationOf(message) { + assert.equal(message[0], 0x0a); + return read(message.subarray(2, 2 + message[1])); +} + +test("receive buffers until delivered and recognises redelivery after", async () => { + const alice = await device("alice-receive"); + const bob = await device("bob-receive"); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + const sent = await alice.signal.encrypt(BOB, 1, paddedConversation("hello from js")); + const args = [ALICE, ALICE, 1, sent.kind, sent.ciphertext, 2, false]; + + const first = await bob.signal.receive(...args); + assert.equal(first.status, "message"); + assert.equal(first.redelivered, false); + assert.equal(conversationOf(first.message), "hello from js"); + + const again = await bob.signal.receive(...args); + assert.equal(again.redelivered, true); + assert.equal(again.receiptKey, first.receiptKey); + + await bob.signal.markDelivered(first.receiptKey); + const after = await bob.signal.receive(...args); + assert.equal(after.status, "already_delivered"); + assert.equal(bob.backend.containsBytes(text("hello from js")), false); +}); From f174381c085cc3729cf5625c07f9b5899aca81ed Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 04:17:33 +0300 Subject: [PATCH 06/17] feat(signal-ops): build outbound DM stanzas inside the Signal service SignalOps::send_direct takes the app's waE2E.Message and builds the finished stanza with wacore's prepare_dm_stanza: padding, own-device copies, per-device encryption and session setup from bundles. The WhatsApp client only answers device-list and prekey queries through SendContextResolver and transmits the bytes, so it never sees plaintext. Session changes and the sent-message record (for retry receipts) commit before the stanza is returned. DeviceKeys moves to format 2, adding the paired ADVSignedDeviceIdentity and own PN/LID JIDs; format 1 still decodes. sign_pairing persists the signed identity and set_own_jids records the JIDs after pair success. Every state-changing operation now holds one lock per device: staged views commit whole batches, so concurrent views on the same session would lose a ratchet step. --- Cargo.lock | 3 + wacore/signal-ops/Cargo.toml | 4 + wacore/signal-ops/src/device.rs | 105 +++++++++++--- wacore/signal-ops/src/error.rs | 4 + wacore/signal-ops/src/inbound.rs | 8 +- wacore/signal-ops/src/lib.rs | 2 + wacore/signal-ops/src/ops.rs | 49 +++++-- wacore/signal-ops/src/outbound.rs | 181 ++++++++++++++++++++++++ wacore/signal-ops/tests/send.rs | 228 ++++++++++++++++++++++++++++++ wacore/signal-wasm/src/convert.rs | 1 + 10 files changed, 548 insertions(+), 37 deletions(-) create mode 100644 wacore/signal-ops/src/outbound.rs create mode 100644 wacore/signal-ops/tests/send.rs diff --git a/Cargo.lock b/Cargo.lock index 8f90af64..3c74796b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5340,6 +5340,7 @@ dependencies = [ name = "wacore-signal-ops" version = "0.1.0" dependencies = [ + "anyhow", "async-lock", "async-trait", "buffa", @@ -5350,7 +5351,9 @@ dependencies = [ "rand 0.10.3", "sha2 0.11.0", "thiserror 2.0.21", + "tokio", "wacore", + "wacore-binary", "wacore-recordstore", "waproto", ] diff --git a/wacore/signal-ops/Cargo.toml b/wacore/signal-ops/Cargo.toml index 8c4946e0..b4215047 100644 --- a/wacore/signal-ops/Cargo.toml +++ b/wacore/signal-ops/Cargo.toml @@ -10,6 +10,7 @@ description = "Signal operations for a WhatsApp linked device whose keys live ou publish = false [dependencies] +anyhow = { workspace = true } async-lock = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } @@ -18,10 +19,13 @@ rand = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } wacore = { workspace = true } +wacore-binary = { workspace = true } wacore-recordstore = { path = "../recordstore" } waproto = { workspace = true } [dev-dependencies] +anyhow = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } buffa = { workspace = true } futures = { workspace = true, features = ["executor"] } hmac = { workspace = true } diff --git a/wacore/signal-ops/src/device.rs b/wacore/signal-ops/src/device.rs index a36b5176..ed85e943 100644 --- a/wacore/signal-ops/src/device.rs +++ b/wacore/signal-ops/src/device.rs @@ -13,6 +13,13 @@ pub struct DeviceKeys { pub signed_prekey_signature: [u8; 64], /// Next one-time prekey ID to hand out. WhatsApp IDs are 24-bit. pub next_prekey_id: u32, + /// Encoded `ADVSignedDeviceIdentity` from pairing. Sent with prekey + /// messages so recipients can verify this device belongs to the account. + pub account: Option>, + /// This device's phone-number JID (`user:device@s.whatsapp.net`). + pub own_pn: Option, + /// This device's LID (`user:device@lid`). + pub own_lid: Option, } /// Everything the WhatsApp client needs to register and pair the device. @@ -41,7 +48,9 @@ pub struct PublicPreKey { } const FORMAT_V1: u8 = 1; -const ENCODED_LEN: usize = 1 + 32 + 4 + 32 + 4 + 32 + 64 + 4; +const FORMAT_V2: u8 = 2; +/// Fixed part shared by both formats; format 2 appends the paired identity. +const FIXED_LEN: usize = 1 + 32 + 4 + 32 + 4 + 32 + 64 + 4; /// One-time prekey IDs are 3 bytes on the wire. pub(crate) const MAX_PREKEY_ID: u32 = 0x00FF_FFFF; @@ -72,6 +81,9 @@ impl DeviceKeys { signed_prekey, signed_prekey_signature, next_prekey_id: 1, + account: None, + own_pn: None, + own_lid: None, }) } @@ -92,12 +104,14 @@ impl DeviceKeys { } } - /// Layout, format 1: version, identity private, registration ID, ADV - /// secret, signed prekey ID, signed prekey private, signature, next - /// prekey ID. Integers are big-endian. Public keys are derived on load. + /// Layout, format 2: version, identity private, registration ID, ADV + /// secret, signed prekey ID, signed prekey private, signature, next prekey + /// ID, then three length-prefixed fields (u32 account, u16 own PN, u16 own + /// LID; length 0 means absent). Integers are big-endian. Public keys are + /// derived on load. Format 1 is the fixed part alone and still decodes. pub fn encode(&self) -> Vec { - let mut out = Vec::with_capacity(ENCODED_LEN); - out.push(FORMAT_V1); + let mut out = Vec::with_capacity(FIXED_LEN + 8); + out.push(FORMAT_V2); out.extend_from_slice(self.identity.private_key.serialize()); out.extend_from_slice(&self.registration_id.to_be_bytes()); out.extend_from_slice(&self.adv_secret); @@ -105,22 +119,33 @@ impl DeviceKeys { out.extend_from_slice(self.signed_prekey.private_key.serialize()); out.extend_from_slice(&self.signed_prekey_signature); out.extend_from_slice(&self.next_prekey_id.to_be_bytes()); + let account = self.account.as_deref().unwrap_or_default(); + out.extend_from_slice(&(account.len() as u32).to_be_bytes()); + out.extend_from_slice(account); + for jid in [&self.own_pn, &self.own_lid] { + let jid = jid.as_deref().unwrap_or_default().as_bytes(); + out.extend_from_slice(&(jid.len() as u16).to_be_bytes()); + out.extend_from_slice(jid); + } out } pub fn decode(bytes: &[u8]) -> Result { let invalid = |what: &str| OpsError::InvalidInput(format!("device keys: {what}")); - if bytes.first() != Some(&FORMAT_V1) { - return Err(invalid("unsupported format")); - } - if bytes.len() != ENCODED_LEN { - return Err(invalid("wrong length")); + let format = *bytes.first().ok_or_else(|| invalid("empty"))?; + match format { + FORMAT_V1 if bytes.len() == FIXED_LEN => {} + FORMAT_V2 if bytes.len() >= FIXED_LEN => {} + FORMAT_V1 | FORMAT_V2 => return Err(invalid("wrong length")), + _ => return Err(invalid("unsupported format")), } let mut at = 1; - let mut take = |len: usize| { - let slice = &bytes[at..at + len]; + let mut take = |len: usize| -> Result<&[u8], OpsError> { + let slice = bytes + .get(at..at + len) + .ok_or_else(|| invalid("truncated"))?; at += len; - slice + Ok(slice) }; let key_pair = |private: &[u8]| -> Result { let private = PrivateKey::deserialize(private).map_err(|_| invalid("private key"))?; @@ -129,14 +154,31 @@ impl DeviceKeys { }; let u32_at = |s: &[u8]| u32::from_be_bytes([s[0], s[1], s[2], s[3]]); - let identity = key_pair(take(32))?; - let registration_id = u32_at(take(4)); - let adv_secret: [u8; 32] = take(32).try_into().map_err(|_| invalid("adv secret"))?; - let signed_prekey_id = u32_at(take(4)); - let signed_prekey = key_pair(take(32))?; + let identity = key_pair(take(32)?)?; + let registration_id = u32_at(take(4)?); + let adv_secret: [u8; 32] = take(32)?.try_into().map_err(|_| invalid("adv secret"))?; + let signed_prekey_id = u32_at(take(4)?); + let signed_prekey = key_pair(take(32)?)?; let signed_prekey_signature: [u8; 64] = - take(64).try_into().map_err(|_| invalid("signature"))?; - let next_prekey_id = u32_at(take(4)); + take(64)?.try_into().map_err(|_| invalid("signature"))?; + let next_prekey_id = u32_at(take(4)?); + let (mut account, mut own_pn, mut own_lid) = (None, None, None); + if format == FORMAT_V2 { + let len = u32_at(take(4)?) as usize; + account = Some(take(len)?.to_vec()).filter(|a| !a.is_empty()); + let mut jid = || -> Result, OpsError> { + let len_bytes = take(2)?; + let len = u16::from_be_bytes([len_bytes[0], len_bytes[1]]) as usize; + let raw = take(len)?; + let text = std::str::from_utf8(raw).map_err(|_| invalid("jid is not UTF-8"))?; + Ok(Some(text.to_owned()).filter(|j| !j.is_empty())) + }; + own_pn = jid()?; + own_lid = jid()?; + } + if at != bytes.len() { + return Err(invalid("trailing bytes")); + } Ok(Self { identity, registration_id, @@ -145,6 +187,9 @@ impl DeviceKeys { signed_prekey, signed_prekey_signature, next_prekey_id, + account, + own_pn, + own_lid, }) } } @@ -191,6 +236,24 @@ mod tests { )); } + #[test] + fn the_paired_identity_round_trips_and_format_1_still_decodes() { + let mut keys = DeviceKeys::generate().unwrap(); + keys.account = Some(vec![1, 2, 3]); + keys.own_pn = Some("15550001111:4@s.whatsapp.net".into()); + keys.own_lid = Some("123456789:4@lid".into()); + let decoded = DeviceKeys::decode(&keys.encode()).unwrap(); + assert_eq!(decoded.account, keys.account); + assert_eq!(decoded.own_pn, keys.own_pn); + assert_eq!(decoded.own_lid, keys.own_lid); + + let mut v1 = keys.encode()[..FIXED_LEN].to_vec(); + v1[0] = FORMAT_V1; + let old = DeviceKeys::decode(&v1).unwrap(); + assert_eq!(old.public(), keys.public()); + assert_eq!(old.account, None); + } + #[test] fn corrupt_encodings_are_rejected() { let mut bytes = DeviceKeys::generate().unwrap().encode(); diff --git a/wacore/signal-ops/src/error.rs b/wacore/signal-ops/src/error.rs index 6a11db9a..e39462fc 100644 --- a/wacore/signal-ops/src/error.rs +++ b/wacore/signal-ops/src/error.rs @@ -14,6 +14,10 @@ pub enum OpsError { /// answers the server with for the same failure. #[error("pairing refused: {code} {text}")] Pairing { code: u16, text: &'static str }, + /// Building an outbound stanza failed, including resolver (device list, + /// prekey fetch) failures reported by the WhatsApp client. + #[error("send failed: {0}")] + Send(String), /// The scope has no device keys yet; call `SignalOps::create`. #[error("device keys are not initialised for this scope")] NotInitialised, diff --git a/wacore/signal-ops/src/inbound.rs b/wacore/signal-ops/src/inbound.rs index e8cc8ed4..e408c82a 100644 --- a/wacore/signal-ops/src/inbound.rs +++ b/wacore/signal-ops/src/inbound.rs @@ -172,13 +172,7 @@ impl SignalOps { /// distributions inside it are stored in the same batch as the decrypt. pub async fn receive(&self, request: ReceiveRequest<'_>) -> Result { let receipt_key = receipt_key(&request); - // Pairwise traffic shares the sender's session lock; group traffic - // serialises per sender chain. - let lock_key = match request.kind { - ReceiveKind::SenderKey => format!("{}\u{0}{}", request.chat, request.sender.as_str()), - _ => request.sender.as_str().to_owned(), - }; - let lock = self.address_lock(&lock_key); + let lock = self.session_lock(); let _guard = lock.lock().await; if let Some(bytes) = self diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index 8487983d..08062e66 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -15,6 +15,7 @@ mod device; mod error; mod inbound; mod ops; +mod outbound; mod stores; pub use device::{DeviceKeys, PublicIdentity, PublicPreKey, PublicSignedPreKey}; @@ -23,3 +24,4 @@ pub use inbound::{ReceiveKind, ReceiveRequest, Received, ReceivedMessage}; pub use ops::{ Decrypted, EncKind, Encrypted, PairingSignature, RemoteBundle, ServiceStore, SignalOps, }; +pub use outbound::PreparedSend; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 9b898b9c..079db657 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -129,14 +129,16 @@ pub struct PairingSignature { /// Signal operations for one scope (one linked device). /// -/// Writes to a session or sender key are serialised per address inside this -/// value; the store's lease fencing protects against a second node. +/// Every operation that changes Signal state holds one lock per device: +/// staged views commit whole batches, so two concurrent views writing the +/// same session would lose a ratchet step. The store's lease fencing protects +/// against a second node. pub struct SignalOps { store: Arc, keys: RwLock>, /// Held while device keys are read-modify-written. device_write: Mutex<()>, - address_locks: KeyedLocks, + session_lock: Arc>, sender_key_locks: Arc, } @@ -177,7 +179,7 @@ impl SignalOps { store, keys: RwLock::new(Arc::new(keys)), device_write: Mutex::new(()), - address_locks: KeyedLocks::default(), + session_lock: Arc::new(Mutex::new(())), sender_key_locks: Arc::new(KeyedLocks::default()), } } @@ -198,8 +200,26 @@ impl SignalOps { &self.store } - pub(crate) fn address_lock(&self, key: &str) -> Arc> { - self.address_locks.get(key) + pub(crate) fn session_lock(&self) -> Arc> { + self.session_lock.clone() + } + + pub(crate) async fn keys(&self) -> Arc { + self.keys.read().await.clone() + } + + /// Record this device's own JIDs once the client knows them (after pair + /// success). Sends need them to build own-device copies. + pub async fn set_own_jids(&self, pn: &str, lid: Option<&str>) -> Result<(), OpsError> { + for jid in std::iter::once(pn).chain(lid) { + jid.parse::() + .map_err(|_| OpsError::InvalidInput(format!("{jid} is not a JID")))?; + } + let _guard = self.device_write.lock().await; + let mut keys = (**self.keys.read().await).clone(); + keys.own_pn = Some(pn.to_owned()); + keys.own_lid = lid.map(str::to_owned); + self.replace_keys(keys).await } async fn replace_keys(&self, keys: DeviceKeys) -> Result<(), OpsError> { @@ -299,7 +319,7 @@ impl SignalOps { address: &ProtocolAddress, bundle: &RemoteBundle, ) -> Result<(), OpsError> { - let lock = self.address_locks.get(address.as_str()); + let lock = self.session_lock.clone(); let _guard = lock.lock().await; let prekey = match bundle.prekey { Some((id, key)) => Some((id.into(), public_key(&key, "prekey")?)), @@ -336,7 +356,7 @@ impl SignalOps { kind: EncKind, ciphertext: &[u8], ) -> Result { - let lock = self.address_locks.get(sender.as_str()); + let lock = self.session_lock.clone(); let _guard = lock.lock().await; let staged = Arc::new(self.store.staged()); let decrypted = self.decrypt_into(&staged, sender, kind, ciphertext).await?; @@ -398,7 +418,7 @@ impl SignalOps { recipient: &ProtocolAddress, plaintext: &[u8], ) -> Result { - let lock = self.address_locks.get(recipient.as_str()); + let lock = self.session_lock.clone(); let _guard = lock.lock().await; let mut sessions = self.stores().await; let mut identities = sessions.clone(); @@ -426,6 +446,8 @@ impl SignalOps { own_address: &ProtocolAddress, ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, own_address.as_str()); + let lock = self.session_lock.clone(); + let _guard = lock.lock().await; let mut store = self.stores().await; let skdm = create_sender_key_distribution_message(&name, &mut store, &mut rng()).await?; Ok(skdm.serialized().to_vec()) @@ -439,6 +461,8 @@ impl SignalOps { plaintext: &[u8], ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, own_address.as_str()); + let lock = self.session_lock.clone(); + let _guard = lock.lock().await; let mut store = self.stores().await; let message = group_encrypt(&mut store, &name, plaintext, &mut rng()).await?; Ok(message.serialized().to_vec()) @@ -494,6 +518,8 @@ impl SignalOps { ciphertext: &[u8], ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, sender.as_str()); + let lock = self.session_lock.clone(); + let _guard = lock.lock().await; let mut store = self.stores().await; Ok(group_decrypt(ciphertext, &mut store, &name).await?) } @@ -515,6 +541,11 @@ impl SignalOps { code: e.code, text: e.text, })?; + // Kept for prekey messages, which carry the device identity. + let _guard = self.device_write.lock().await; + let mut updated = (**self.keys.read().await).clone(); + updated.account = Some(signed_identity.clone()); + self.replace_keys(updated).await?; Ok(PairingSignature { signed_identity, key_index, diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs new file mode 100644 index 00000000..371f5633 --- /dev/null +++ b/wacore/signal-ops/src/outbound.rs @@ -0,0 +1,181 @@ +//! Outbound messages built inside the Signal service. +//! +//! The customer's app hands the service a `waE2E.Message`; wacore's stanza +//! builder pads it, adds own-device copies and encrypts it per device. The +//! WhatsApp client only answers device-list and prekey queries through the +//! [`SendContextResolver`] and transmits the finished stanza, so it never sees +//! the plaintext. Session changes and the sent-message record commit before +//! the stanza is returned, matching wacore's rule that Signal state is durable +//! before ciphertext is published. + +use crate::error::OpsError; +use crate::ops::{ServiceStore, SignalOps}; +use wacore::client::context::SendContextResolver; +use wacore::runtime::Runtime; +use wacore::send::{DmStanzaRequest, ResolvedDmDevices, SignalStores, prepare_dm_stanza}; +use wacore_binary::{Jid, JidExt}; +use wacore_recordstore::Namespace; +use waproto::whatsapp as wa; + +/// A finished `` stanza for the client to transmit. +#[derive(Debug, Clone)] +pub struct PreparedSend { + /// The stanza, marshaled as the binary node format the socket writes. + pub stanza: Vec, + /// Devices that were addressed but got no ``. + pub unreached_devices: Vec, + /// Device-list hash of the addressed set, for comparing with the server + /// ack. + pub phash: Option, +} + +fn sent_key(chat: &Jid, message_id: &str) -> String { + format!("{chat}\u{0}{message_id}") +} + +/// Sent-message record: `[has_secret][secret (32 bytes, when set)][message]`. +fn encode_sent(message: &[u8], secret: Option<&[u8; 32]>) -> Vec { + let mut out = Vec::with_capacity(1 + 32 + message.len()); + match secret { + Some(secret) => { + out.push(1); + out.extend_from_slice(secret); + } + None => out.push(0), + } + out.extend_from_slice(message); + out +} + +fn send_error(error: anyhow::Error) -> OpsError { + OpsError::Send(format!("{error:#}")) +} + +impl SignalOps { + /// Build a direct-message stanza for `to`. Devices come from the + /// resolver (the WhatsApp client); missing sessions are set up from + /// bundles it fetches. + pub async fn send_direct( + &self, + runtime: &dyn Runtime, + resolver: &dyn SendContextResolver, + to: &Jid, + message: &wa::Message, + message_id: &str, + ) -> Result { + let keys = self.keys().await; + let own_jid: Jid = keys + .own_pn + .as_deref() + .ok_or_else(|| OpsError::InvalidInput("device is not paired: own JID unknown".into()))? + .parse() + .map_err(|_| OpsError::InvalidInput("stored own JID does not parse".into()))?; + let own_lid: Option = keys + .own_lid + .as_deref() + .map(str::parse) + .transpose() + .map_err(|_| OpsError::InvalidInput("stored own LID does not parse".into()))?; + let account = keys + .account + .as_deref() + .map(waproto::codec::adv_signed_device_identity_decode) + .transpose() + .map_err(|_| { + OpsError::InvalidInput("stored account identity does not decode".into()) + })?; + + let mut devices = resolver + .resolve_devices(&[to.to_non_ad(), own_jid.to_non_ad()]) + .await + .map_err(send_error)?; + // Hosted (Cloud API) devices never get a copy, as in the client. + devices.retain(|device| !device.is_hosted()); + wacore::types::jid::sort_dedup_by_device(&mut devices); + let resolved = ResolvedDmDevices::new(devices, &own_jid, own_lid.as_ref()); + + let lock = self.session_lock(); + let _guard = lock.lock().await; + let staged = std::sync::Arc::new(self.store().staged()); + let stores = self.stores_over(staged.clone()).await; + let (mut sender_keys, mut sessions, mut identities, mut prekeys, signed_prekeys) = ( + stores.clone(), + stores.clone(), + stores.clone(), + stores.clone(), + stores, + ); + let mut signal = SignalStores { + sender_key_store: &mut sender_keys, + session_store: &mut sessions, + identity_store: &mut identities, + prekey_store: &mut prekeys, + signed_prekey_store: &signed_prekeys, + }; + let prepared = prepare_dm_stanza( + runtime, + &mut signal, + resolver, + DmStanzaRequest { + own_jid: &own_jid, + own_lid: own_lid.as_ref(), + account: account.as_ref(), + to, + message, + message_id, + edit: None, + extra_nodes: &[], + devices: &resolved, + pre_encoded: None, + }, + ) + .await + .map_err(send_error)?; + + let encoded = waproto::codec::message_to_vec(message); + staged + .put_aux( + Namespace::SentMessage, + &sent_key(to, message_id), + &encode_sent(&encoded, prepared.message_secret.as_ref()), + ) + .await?; + staged.commit().await?; + + let stanza = wacore_binary::marshal(&prepared.node) + .map_err(|e| OpsError::Send(format!("stanza did not marshal: {e}")))?; + Ok(PreparedSend { + stanza, + unreached_devices: prepared.unreached_devices, + phash: prepared.phash.map(|p| p.to_string()), + }) + } + + /// The message this device sent to `chat` with `message_id`, if still + /// kept. Used to answer retry receipts. + pub async fn sent_message( + &self, + chat: &Jid, + message_id: &str, + ) -> Result, OpsError> { + let Some(record) = self + .store() + .load_aux(Namespace::SentMessage, &sent_key(chat, message_id)) + .await? + else { + return Ok(None); + }; + let body = match record.first() { + Some(0) => &record[1..], + Some(1) if record.len() >= 33 => &record[33..], + _ => { + return Err(OpsError::InvalidInput( + "sent-message record is corrupt".into(), + )); + } + }; + waproto::codec::message_decode(body) + .map(Some) + .map_err(|e| OpsError::InvalidInput(format!("sent message did not decode: {e}"))) + } +} diff --git a/wacore/signal-ops/tests/send.rs b/wacore/signal-ops/tests/send.rs new file mode 100644 index 00000000..58520ac1 --- /dev/null +++ b/wacore/signal-ops/tests/send.rs @@ -0,0 +1,228 @@ +//! Outbound: the service builds the stanza, the client only resolves devices +//! and bundles, and the recipient decrypts what was built. + +use async_trait::async_trait; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use wacore::client::context::{GroupRoutingInfo, SendContextResolver}; +use wacore::libsignal::protocol::{DeviceId, IdentityKey, PreKeyBundle, PublicKey}; +use wacore::prekeys::PreKeyFetchOutcome; +use wacore::runtime::{AbortHandle, Runtime}; +use wacore::types::jid::JidExt as _; +use wacore_binary::{Jid, NodeContent}; +use wacore_recordstore::{AesGcmSealer, LeaseStore, MemoryRecordStore, RecordSignalStore}; +use wacore_signal_ops::{ReceiveKind, ReceiveRequest, Received, SignalOps}; +use waproto::whatsapp as wa; + +type Store = RecordSignalStore; +type BoxFuture = Pin + Send + 'static>>; + +struct TokioRuntime; + +impl Runtime for TokioRuntime { + fn spawn(&self, future: BoxFuture) -> AbortHandle { + let task = tokio::spawn(future); + AbortHandle::new(move || task.abort()) + } + + fn sleep(&self, duration: Duration) -> Pin + Send>> { + Box::pin(tokio::time::sleep(duration)) + } + + fn spawn_blocking(&self, f: Box) -> BoxFuture { + Box::pin(async move { + let _ = tokio::task::spawn_blocking(f).await; + }) + } + + fn yield_now(&self) -> Option + Send>>> { + Some(Box::pin(tokio::task::yield_now())) + } +} + +async fn device(scope: &str, own_pn: &str) -> SignalOps { + let records = Arc::new(MemoryRecordStore::new()); + let lease = records + .acquire(scope, "node-a", 0, 60_000) + .await + .unwrap() + .unwrap(); + let store = Arc::new(RecordSignalStore::new( + records, + Arc::new(AesGcmSealer::new(&[5; 32])), + lease.fence, + )); + let ops = SignalOps::create(store).await.unwrap(); + ops.set_own_jids(own_pn, None).await.unwrap(); + ops +} + +/// Stands in for the WhatsApp client: answers device-list and prekey +/// queries from a fixed table, as the runner would from the server. +struct FakeClient { + devices: Vec, + bundles: HashMap, +} + +#[async_trait] +impl SendContextResolver for FakeClient { + async fn resolve_devices(&self, _jids: &[Jid]) -> Result, anyhow::Error> { + Ok(self.devices.clone()) + } + + async fn fetch_prekeys( + &self, + jids: &[Jid], + ) -> Result, anyhow::Error> { + Ok(jids + .iter() + .filter_map(|jid| self.bundles.get(jid).map(|b| (jid.clone(), b.clone()))) + .collect()) + } + + async fn fetch_prekeys_for_identity_check( + &self, + jids: &[Jid], + ) -> Result { + Ok(PreKeyFetchOutcome { + bundles: self.fetch_prekeys(jids).await?, + rejected: Vec::new(), + }) + } + + async fn resolve_group_routing_info( + &self, + _jid: &Jid, + ) -> Result, anyhow::Error> { + anyhow::bail!("not a group test") + } +} + +async fn bundle_for(ops: &SignalOps) -> PreKeyBundle { + let identity = ops.public_identity().await; + let prekey = ops.generate_prekeys(1).await.unwrap().remove(0); + PreKeyBundle::new( + identity.registration_id, + DeviceId::from(1), + Some(( + prekey.id.into(), + PublicKey::from_djb_public_key_bytes(&prekey.public_key).unwrap(), + )), + identity.signed_prekey.id.into(), + PublicKey::from_djb_public_key_bytes(&identity.signed_prekey.public_key).unwrap(), + identity.signed_prekey.signature, + IdentityKey::new(PublicKey::from_djb_public_key_bytes(&identity.identity_key).unwrap()), + ) + .unwrap() +} + +/// The `` addressed to `device` inside a marshaled stanza. +fn enc_for(stanza: &[u8], device: &Jid) -> (String, u8, Vec) { + let node = wacore_binary::marshal::unmarshal_packed_ref(stanza) + .unwrap() + .to_owned(); + let participants = node.get_optional_child("participants").expect("fan-out"); + let to = participants + .get_children_by_tag("to") + .find(|to| { + to.attrs().optional_string("jid").as_deref() == Some(device.to_string().as_str()) + }) + .expect("an entry for the device"); + let enc = to.get_optional_child("enc").expect("enc"); + let kind = enc.attrs().optional_string("type").unwrap().to_string(); + let version = enc + .attrs() + .optional_string("v") + .map_or(2, |v| v.parse().unwrap()); + let Some(NodeContent::Bytes(bytes)) = &enc.content else { + panic!("enc without bytes") + }; + (kind, version, bytes.clone()) +} + +fn text(body: &str) -> wa::Message { + wa::Message { + conversation: Some(body.to_owned()), + ..Default::default() + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_stanza_built_by_the_service_decrypts_at_the_recipient() { + let alice_jid: Jid = "111:1@s.whatsapp.net".parse().unwrap(); + let bob_jid: Jid = "222:1@s.whatsapp.net".parse().unwrap(); + let alice = device("alice-send", "111:1@s.whatsapp.net").await; + let bob = device("bob-send", "222:1@s.whatsapp.net").await; + let client = FakeClient { + devices: vec![bob_jid.clone()], + bundles: HashMap::from([(bob_jid.clone(), bundle_for(&bob).await)]), + }; + let to = bob_jid.to_non_ad(); + + for (round, body) in ["first message", "second message"].into_iter().enumerate() { + let id = format!("MSG{round}"); + let sent = alice + .send_direct(&TokioRuntime, &client, &to, &text(body), &id) + .await + .unwrap(); + assert!(sent.unreached_devices.is_empty()); + let (kind, version, ciphertext) = enc_for(&sent.stanza, &bob_jid); + // Until the recipient replies, every message on a new session is a + // prekey message (libsignal's unacknowledged-prekey state). + assert_eq!(kind, "pkmsg"); + + let sender = alice_jid.to_protocol_address(); + let received = bob + .receive(ReceiveRequest { + chat: &alice_jid.to_non_ad().to_string(), + sender: &sender, + kind: if kind == "pkmsg" { + ReceiveKind::PreKey + } else { + ReceiveKind::Message + }, + ciphertext: &ciphertext, + padding_version: version, + is_from_me: false, + }) + .await + .unwrap(); + let Received::Message(message) = received else { + panic!("expected a message") + }; + assert_eq!(message.content.message.conversation.as_deref(), Some(body)); + + let kept = alice.sent_message(&to, &id).await.unwrap().unwrap(); + assert_eq!(kept.conversation.as_deref(), Some(body)); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn an_unpaired_device_refuses_to_send() { + let records = Arc::new(MemoryRecordStore::new()); + let lease = records + .acquire("unpaired", "node-a", 0, 60_000) + .await + .unwrap() + .unwrap(); + let ops = SignalOps::create(Arc::new(RecordSignalStore::new( + records, + Arc::new(AesGcmSealer::new(&[5; 32])), + lease.fence, + ))) + .await + .unwrap(); + let client = FakeClient { + devices: Vec::new(), + bundles: HashMap::new(), + }; + let to: Jid = "222@s.whatsapp.net".parse().unwrap(); + assert!( + ops.send_direct(&TokioRuntime, &client, &to, &text("x"), "ID") + .await + .is_err() + ); +} diff --git a/wacore/signal-wasm/src/convert.rs b/wacore/signal-wasm/src/convert.rs index 6f344214..19b8f8cf 100644 --- a/wacore/signal-wasm/src/convert.rs +++ b/wacore/signal-wasm/src/convert.rs @@ -39,6 +39,7 @@ pub fn ops_error(error: OpsError) -> JsValue { } OpsError::InvalidInput(message) => coded_error("invalid_input", &message), OpsError::NotInitialised => coded_error("not_initialised", "scope has no device keys"), + OpsError::Send(message) => coded_error("send", &message), OpsError::Signal(e) => coded_error("signal", &e.to_string()), OpsError::Store(e) => coded_error("store", &error_chain(&e)), } From fa844183272159ea977790a9061de4888151f20f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 04:37:02 +0300 Subject: [PATCH 07/17] feat(signal-ops): group sends with tracked sender-key distribution SignalOps::send_group builds the group stanza with wacore's prepare_group_stanza: the skmsg plus, for devices that do not hold this device's sender key yet, the key encrypted pairwise. Membership and addressing come from the resolver, and LID-mode members are queried by phone number then mapped back, as the client does. A missing own chain forces distribution to every member device. Otherwise only devices not marked warm receive the key, and a companion counts as warm only when its primary does (WA Web's primary-device gate). mark_sender_key_distributed records devices after the server acks, never this account's own devices (WA Web's !isMeDevice); forget_sender_key_devices resets a group after membership changes. Marks live in a sealed SenderKeyDevices namespace. --- wacore/recordstore/src/record.rs | 6 +- wacore/recordstore/src/signal.rs | 3 +- wacore/signal-ops/src/lib.rs | 2 +- wacore/signal-ops/src/outbound.rs | 273 ++++++++++++++++++++++++++++-- wacore/signal-ops/tests/send.rs | 149 ++++++++++++++-- 5 files changed, 402 insertions(+), 31 deletions(-) diff --git a/wacore/recordstore/src/record.rs b/wacore/recordstore/src/record.rs index 4804bb4a..8fe693d4 100644 --- a/wacore/recordstore/src/record.rs +++ b/wacore/recordstore/src/record.rs @@ -18,10 +18,12 @@ pub enum Namespace { DecryptBuffer, /// Messages this device sent, kept so a retry receipt can re-encrypt them. SentMessage, + /// Group member devices confirmed to hold this device's sender key. + SenderKeyDevices, } impl Namespace { - pub const ALL: [Namespace; 8] = [ + pub const ALL: [Namespace; 9] = [ Namespace::Identity, Namespace::Session, Namespace::PreKey, @@ -30,6 +32,7 @@ impl Namespace { Namespace::Device, Namespace::DecryptBuffer, Namespace::SentMessage, + Namespace::SenderKeyDevices, ]; /// Stable persisted name. @@ -43,6 +46,7 @@ impl Namespace { Namespace::Device => "device", Namespace::DecryptBuffer => "decrypt_buffer", Namespace::SentMessage => "sent_message", + Namespace::SenderKeyDevices => "sender_key_devices", } } } diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 83f4bcf4..56fec577 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -16,10 +16,11 @@ const PREKEY_UPLOADED: u8 = 1; /// Namespaces the Signal service reads and writes directly, outside the /// [`SignalStore`] surface. -const AUX_NAMESPACES: [Namespace; 3] = [ +const AUX_NAMESPACES: [Namespace; 4] = [ Namespace::Device, Namespace::DecryptBuffer, Namespace::SentMessage, + Namespace::SenderKeyDevices, ]; /// A write before sealing. diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index 08062e66..c7f2b5b4 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -24,4 +24,4 @@ pub use inbound::{ReceiveKind, ReceiveRequest, Received, ReceivedMessage}; pub use ops::{ Decrypted, EncKind, Encrypted, PairingSignature, RemoteBundle, ServiceStore, SignalOps, }; -pub use outbound::PreparedSend; +pub use outbound::{PreparedGroupSend, PreparedSend}; diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs index 371f5633..0f3d2475 100644 --- a/wacore/signal-ops/src/outbound.rs +++ b/wacore/signal-ops/src/outbound.rs @@ -10,9 +10,17 @@ use crate::error::OpsError; use crate::ops::{ServiceStore, SignalOps}; +use std::collections::HashSet; use wacore::client::context::SendContextResolver; +use wacore::libsignal::store::sender_key_name::SenderKeyName; use wacore::runtime::Runtime; -use wacore::send::{DmStanzaRequest, ResolvedDmDevices, SignalStores, prepare_dm_stanza}; +use wacore::send::{ + DmStanzaRequest, GroupStanzaRequest, ResolvedDmDevices, ResolvedGroupDevices, + SenderKeyDistributionPolicy, SignalStores, prepare_dm_stanza, prepare_group_stanza, + retain_skdm_distribution_targets, +}; +use wacore::types::jid::JidExt as _; +use wacore::types::message::AddressingMode; use wacore_binary::{Jid, JidExt}; use wacore_recordstore::Namespace; use waproto::whatsapp as wa; @@ -51,26 +59,39 @@ fn send_error(error: anyhow::Error) -> OpsError { OpsError::Send(format!("{error:#}")) } +/// A finished group `` stanza. +#[derive(Debug, Clone)] +pub struct PreparedGroupSend { + pub stanza: Vec, + /// Devices this send distributed the sender key to. Once the server acks + /// the stanza, pass them to [`SignalOps::mark_sender_key_distributed`]. + pub distribution_targets: Vec, + /// Users whose devices were reported unregistered while fetching bundles; + /// the client should refresh their device lists. + pub stale_device_users: Vec, + pub phash: Option, +} + +struct OwnIdentity { + pn: Jid, + lid: Option, + account: Option, +} + +fn sender_key_device_key(group: &str, device: &Jid) -> String { + format!("{group}\u{0}{device}") +} + impl SignalOps { - /// Build a direct-message stanza for `to`. Devices come from the - /// resolver (the WhatsApp client); missing sessions are set up from - /// bundles it fetches. - pub async fn send_direct( - &self, - runtime: &dyn Runtime, - resolver: &dyn SendContextResolver, - to: &Jid, - message: &wa::Message, - message_id: &str, - ) -> Result { + async fn own_identity(&self) -> Result { let keys = self.keys().await; - let own_jid: Jid = keys + let pn: Jid = keys .own_pn .as_deref() .ok_or_else(|| OpsError::InvalidInput("device is not paired: own JID unknown".into()))? .parse() .map_err(|_| OpsError::InvalidInput("stored own JID does not parse".into()))?; - let own_lid: Option = keys + let lid: Option = keys .own_lid .as_deref() .map(str::parse) @@ -84,6 +105,25 @@ impl SignalOps { .map_err(|_| { OpsError::InvalidInput("stored account identity does not decode".into()) })?; + Ok(OwnIdentity { pn, lid, account }) + } + + /// Build a direct-message stanza for `to`. Devices come from the + /// resolver (the WhatsApp client); missing sessions are set up from + /// bundles it fetches. + pub async fn send_direct( + &self, + runtime: &dyn Runtime, + resolver: &dyn SendContextResolver, + to: &Jid, + message: &wa::Message, + message_id: &str, + ) -> Result { + let OwnIdentity { + pn: own_jid, + lid: own_lid, + account, + } = self.own_identity().await?; let mut devices = resolver .resolve_devices(&[to.to_non_ad(), own_jid.to_non_ad()]) @@ -178,4 +218,209 @@ impl SignalOps { .map(Some) .map_err(|e| OpsError::InvalidInput(format!("sent message did not decode: {e}"))) } + + /// Build a group stanza: a sender-key message for the group plus, for + /// devices that do not yet hold this device's sender key, the key itself + /// encrypted pairwise. Membership and addressing come from the resolver. + pub async fn send_group( + &self, + runtime: &dyn Runtime, + resolver: &dyn SendContextResolver, + group: &Jid, + message: &wa::Message, + message_id: &str, + ) -> Result { + let OwnIdentity { + pn: own_jid, + lid: own_lid, + account, + } = self.own_identity().await?; + let own_lid = own_lid.ok_or_else(|| { + OpsError::InvalidInput("group sends need this device's LID; call set_own_jids".into()) + })?; + let routing = resolver + .resolve_group_routing_info(group) + .await + .map_err(send_error)?; + let is_lid_mode = routing.addressing_mode == AddressingMode::Lid; + let own_sending = if is_lid_mode { &own_lid } else { &own_jid }; + + // Same device query as the client's resolve_group_devices: LID-mode + // members are queried by phone number, then mapped back. + let mut queries: Vec = routing + .participants + .iter() + .map(|jid| { + if is_lid_mode + && jid.is_lid() + && let Some(pn) = routing.phone_jid_for_lid_user(&jid.user) + { + return pn.to_non_ad(); + } + jid.to_non_ad() + }) + .collect(); + if !routing + .participants + .iter() + .any(|participant| participant.is_same_user_as(own_sending)) + { + queries.push(own_jid.to_non_ad()); + } + let mut devices = resolver + .resolve_devices(&queries) + .await + .map_err(send_error)?; + if is_lid_mode { + devices = devices + .into_iter() + .map(|device| routing.phone_device_jid_into_lid(device)) + .collect(); + } + wacore::types::jid::sort_dedup_by_device(&mut devices); + let addressed = ResolvedGroupDevices::new(devices); + + let group_str = group.to_string(); + let lock = self.session_lock(); + let _guard = lock.lock().await; + let own_chain = + SenderKeyName::from_parts(&group_str, own_sending.to_protocol_address().as_str()); + // A missing chain means a fresh key: everyone needs it, as in the + // client's force_skdm path. + let force = self + .store() + .get_sender_key(own_chain.cache_key()) + .await? + .is_none(); + let targets = if force { + let mut all = addressed.devices().to_vec(); + retain_skdm_distribution_targets(&mut all, own_sending); + all + } else { + let warm: HashSet = self + .store() + .scan_aux(Namespace::SenderKeyDevices, &format!("{group_str}\u{0}")) + .await? + .into_iter() + .collect(); + let is_warm = |jid: &Jid| warm.contains(&sender_key_device_key(&group_str, jid)); + addressed + .devices() + .iter() + .filter(|device| { + !device.is_hosted() + && !(device.user == own_sending.user && device.device == own_sending.device) + // WA Web treats a companion as warm only if its + // primary (device 0) is too. + && !(is_warm(device) && is_warm(&device.with_device(0))) + }) + .cloned() + .collect() + }; + + let staged = std::sync::Arc::new(self.store().staged()); + let stores = self.stores_over(staged.clone()).await; + let (mut sender_keys, mut sessions, mut identities, mut prekeys, signed_prekeys) = ( + stores.clone(), + stores.clone(), + stores.clone(), + stores.clone(), + stores, + ); + let mut signal = SignalStores { + sender_key_store: &mut sender_keys, + session_store: &mut sessions, + identity_store: &mut identities, + prekey_store: &mut prekeys, + signed_prekey_store: &signed_prekeys, + }; + let prepared = prepare_group_stanza( + runtime, + &mut signal, + resolver, + GroupStanzaRequest { + group: &routing, + own_jid: &own_jid, + own_lid: &own_lid, + account: account.as_ref(), + to: group, + message, + message_id, + force_distribution: force, + distribution_targets: Some(targets), + distribution_policy: SenderKeyDistributionPolicy::BestEffort, + phash_devices: Some(&addressed), + edit: None, + extra_nodes: &[], + pre_encoded: None, + }, + ) + .await + .map_err(send_error)?; + + let encoded = waproto::codec::message_to_vec(message); + staged + .put_aux( + Namespace::SentMessage, + &sent_key(group, message_id), + &encode_sent(&encoded, prepared.message_secret.as_ref()), + ) + .await?; + staged.commit().await?; + + let stanza = wacore_binary::marshal(&prepared.node) + .map_err(|e| OpsError::Send(format!("stanza did not marshal: {e}")))?; + Ok(PreparedGroupSend { + stanza, + distribution_targets: prepared.skdm_devices, + stale_device_users: prepared.stale_device_users, + phash: prepared.phash.map(|p| p.to_string()), + }) + } + + /// Record that the server acked a group send whose stanza carried the + /// sender key to `devices`. This account's own devices are never recorded + /// (WA Web's `!isMeDevice`), so they get the key on every send. + pub async fn mark_sender_key_distributed( + &self, + group: &Jid, + devices: &[Jid], + ) -> Result<(), OpsError> { + let OwnIdentity { pn, lid, .. } = self.own_identity().await?; + let group_str = group.to_string(); + let lock = self.session_lock(); + let _guard = lock.lock().await; + for device in devices { + if device.is_same_user_as(&pn) + || lid.as_ref().is_some_and(|l| device.is_same_user_as(l)) + { + continue; + } + self.store() + .put_aux( + Namespace::SenderKeyDevices, + &sender_key_device_key(&group_str, device), + &[1], + ) + .await?; + } + Ok(()) + } + + /// Forget which devices hold the sender key for `group`, so the next send + /// distributes it again. Use after membership changes. + pub async fn forget_sender_key_devices(&self, group: &Jid) -> Result<(), OpsError> { + let prefix = format!("{group}\u{0}"); + let lock = self.session_lock(); + let _guard = lock.lock().await; + let keys = self + .store() + .scan_aux(Namespace::SenderKeyDevices, &prefix) + .await?; + let refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + self.store() + .delete_aux(Namespace::SenderKeyDevices, &refs) + .await?; + Ok(()) + } } diff --git a/wacore/signal-ops/tests/send.rs b/wacore/signal-ops/tests/send.rs index 58520ac1..e76dc9b9 100644 --- a/wacore/signal-ops/tests/send.rs +++ b/wacore/signal-ops/tests/send.rs @@ -44,6 +44,10 @@ impl Runtime for TokioRuntime { } async fn device(scope: &str, own_pn: &str) -> SignalOps { + device_with_lid(scope, own_pn, None).await +} + +async fn device_with_lid(scope: &str, own_pn: &str, own_lid: Option<&str>) -> SignalOps { let records = Arc::new(MemoryRecordStore::new()); let lease = records .acquire(scope, "node-a", 0, 60_000) @@ -56,7 +60,7 @@ async fn device(scope: &str, own_pn: &str) -> SignalOps { lease.fence, )); let ops = SignalOps::create(store).await.unwrap(); - ops.set_own_jids(own_pn, None).await.unwrap(); + ops.set_own_jids(own_pn, own_lid).await.unwrap(); ops } @@ -65,6 +69,7 @@ async fn device(scope: &str, own_pn: &str) -> SignalOps { struct FakeClient { devices: Vec, bundles: HashMap, + group: Option>, } #[async_trait] @@ -97,7 +102,9 @@ impl SendContextResolver for FakeClient { &self, _jid: &Jid, ) -> Result, anyhow::Error> { - anyhow::bail!("not a group test") + self.group + .clone() + .ok_or_else(|| anyhow::anyhow!("not a group test")) } } @@ -119,19 +126,13 @@ async fn bundle_for(ops: &SignalOps) -> PreKeyBundle { .unwrap() } -/// The `` addressed to `device` inside a marshaled stanza. -fn enc_for(stanza: &[u8], device: &Jid) -> (String, u8, Vec) { - let node = wacore_binary::marshal::unmarshal_packed_ref(stanza) +fn parse(stanza: &[u8]) -> wacore_binary::Node { + wacore_binary::marshal::unmarshal_packed_ref(stanza) .unwrap() - .to_owned(); - let participants = node.get_optional_child("participants").expect("fan-out"); - let to = participants - .get_children_by_tag("to") - .find(|to| { - to.attrs().optional_string("jid").as_deref() == Some(device.to_string().as_str()) - }) - .expect("an entry for the device"); - let enc = to.get_optional_child("enc").expect("enc"); + .to_owned() +} + +fn enc_parts(enc: &wacore_binary::Node) -> (String, u8, Vec) { let kind = enc.attrs().optional_string("type").unwrap().to_string(); let version = enc .attrs() @@ -143,6 +144,31 @@ fn enc_for(stanza: &[u8], device: &Jid) -> (String, u8, Vec) { (kind, version, bytes.clone()) } +/// The pairwise `` addressed to `device`, if the stanza has one. +fn pairwise_enc_for(stanza: &[u8], device: &Jid) -> Option<(String, u8, Vec)> { + let node = parse(stanza); + let participants = node.get_optional_child("participants")?; + let to = participants.get_children_by_tag("to").find(|to| { + to.attrs().optional_string("jid").as_deref() == Some(device.to_string().as_str()) + })?; + Some(enc_parts(to.get_optional_child("enc").expect("enc"))) +} + +/// The `` addressed to `device` inside a marshaled stanza. +fn enc_for(stanza: &[u8], device: &Jid) -> (String, u8, Vec) { + pairwise_enc_for(stanza, device).expect("an entry for the device") +} + +/// The group-wide `skmsg` ``. +fn skmsg_of(stanza: &[u8]) -> (String, u8, Vec) { + let node = parse(stanza); + let enc = node + .get_children_by_tag("enc") + .find(|e| e.attrs().optional_string("type").as_deref() == Some("skmsg")) + .expect("skmsg"); + enc_parts(enc) +} + fn text(body: &str) -> wa::Message { wa::Message { conversation: Some(body.to_owned()), @@ -159,6 +185,7 @@ async fn a_stanza_built_by_the_service_decrypts_at_the_recipient() { let client = FakeClient { devices: vec![bob_jid.clone()], bundles: HashMap::from([(bob_jid.clone(), bundle_for(&bob).await)]), + group: None, }; let to = bob_jid.to_non_ad(); @@ -218,6 +245,7 @@ async fn an_unpaired_device_refuses_to_send() { let client = FakeClient { devices: Vec::new(), bundles: HashMap::new(), + group: None, }; let to: Jid = "222@s.whatsapp.net".parse().unwrap(); assert!( @@ -226,3 +254,96 @@ async fn an_unpaired_device_refuses_to_send() { .is_err() ); } + +#[tokio::test(flavor = "multi_thread")] +async fn a_group_send_distributes_the_sender_key_once_then_reuses_it() { + use wacore::types::message::AddressingMode; + + let group: Jid = "120363000000001@g.us".parse().unwrap(); + let alice_jid: Jid = "111:1@s.whatsapp.net".parse().unwrap(); + // A primary device, so the primary-device gate is met once it is marked. + let bob_jid: Jid = "222@s.whatsapp.net".parse().unwrap(); + let alice = device_with_lid( + "alice-group-send", + "111:1@s.whatsapp.net", + Some("900111:1@lid"), + ) + .await; + let bob = device("bob-group-send", "222@s.whatsapp.net").await; + let client = FakeClient { + devices: vec![alice_jid.clone(), bob_jid.clone()], + bundles: HashMap::from([(bob_jid.clone(), bundle_for(&bob).await)]), + group: Some(Arc::new(GroupRoutingInfo::new( + vec![alice_jid.to_non_ad(), bob_jid.clone()], + AddressingMode::Pn, + ))), + }; + let alice_addr = alice_jid.to_protocol_address(); + let group_str = group.to_string(); + let receive = |kind: &str, version: u8, ciphertext: Vec| { + let sender = alice_addr.clone(); + let chat = group_str.clone(); + let kind = match kind { + "pkmsg" => ReceiveKind::PreKey, + "msg" => ReceiveKind::Message, + _ => ReceiveKind::SenderKey, + }; + let bob = &bob; + async move { + bob.receive(ReceiveRequest { + chat: &chat, + sender: &sender, + kind, + ciphertext: &ciphertext, + padding_version: version, + is_from_me: false, + }) + .await + .unwrap() + } + }; + + let first = alice + .send_group(&TokioRuntime, &client, &group, &text("hello group"), "G1") + .await + .unwrap(); + assert_eq!(first.distribution_targets, vec![bob_jid.clone()]); + let (kind, version, carrier) = pairwise_enc_for(&first.stanza, &bob_jid).expect("key for bob"); + let Received::Message(_) = receive(&kind, version, carrier).await else { + panic!("sender key carrier was not delivered") + }; + let (_, version, skmsg) = skmsg_of(&first.stanza); + let Received::Message(message) = receive("skmsg", version, skmsg).await else { + panic!("expected the group message") + }; + assert_eq!( + message.content.message.conversation.as_deref(), + Some("hello group") + ); + + alice + .mark_sender_key_distributed(&group, &first.distribution_targets) + .await + .unwrap(); + let second = alice + .send_group(&TokioRuntime, &client, &group, &text("second"), "G2") + .await + .unwrap(); + assert!(second.distribution_targets.is_empty()); + assert!(pairwise_enc_for(&second.stanza, &bob_jid).is_none()); + let (_, version, skmsg) = skmsg_of(&second.stanza); + let Received::Message(message) = receive("skmsg", version, skmsg).await else { + panic!("expected the group message") + }; + assert_eq!( + message.content.message.conversation.as_deref(), + Some("second") + ); + + alice.forget_sender_key_devices(&group).await.unwrap(); + let third = alice + .send_group(&TokioRuntime, &client, &group, &text("third"), "G3") + .await + .unwrap(); + assert_eq!(third.distribution_targets, vec![bob_jid.clone()]); +} From 4ea92bcb57481fb4e6e8e60c259e3e28a6d48e62 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 04:41:17 +0300 Subject: [PATCH 08/17] feat(signal-ops): re-encrypt sent messages for retry receipts SignalOps::resend answers a retry receipt from the sent-message store with wacore's prepare_pairwise_retry_stanza, using the client's routes: direct to the requesting device, or to the group/list with the requester as participant. Keys carried by the receipt start a fresh session; without a session or keys, a bundle is fetched through the resolver, as the client does. Status retransmission is not handled here. establish_session now stages and commits atomically through a shared establish_into. Tests cover a lost delivery and a device reset, with the pairing helper moved to tests/common. --- wacore/signal-ops/src/lib.rs | 2 +- wacore/signal-ops/src/ops.rs | 16 ++- wacore/signal-ops/src/outbound.rs | 115 ++++++++++++++++++++- wacore/signal-ops/tests/common/mod.rs | 58 +++++++++++ wacore/signal-ops/tests/end_to_end.rs | 51 +--------- wacore/signal-ops/tests/send.rs | 140 ++++++++++++++++++++++++++ 6 files changed, 330 insertions(+), 52 deletions(-) create mode 100644 wacore/signal-ops/tests/common/mod.rs diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index c7f2b5b4..d20cf3c3 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -24,4 +24,4 @@ pub use inbound::{ReceiveKind, ReceiveRequest, Received, ReceivedMessage}; pub use ops::{ Decrypted, EncKind, Encrypted, PairingSignature, RemoteBundle, ServiceStore, SignalOps, }; -pub use outbound::{PreparedGroupSend, PreparedSend}; +pub use outbound::{PreparedGroupSend, PreparedSend, RetryRequest, RetryRoute}; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 079db657..8858d182 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -321,6 +321,20 @@ impl SignalOps { ) -> Result<(), OpsError> { let lock = self.session_lock.clone(); let _guard = lock.lock().await; + let staged = Arc::new(self.store.staged()); + self.establish_into(&staged, address, bundle).await?; + staged.commit().await?; + Ok(()) + } + + /// X3DH into a staged view without committing it. The caller holds the + /// session lock and commits. + pub(crate) async fn establish_into( + &self, + staged: &Arc, + address: &ProtocolAddress, + bundle: &RemoteBundle, + ) -> Result<(), OpsError> { let prekey = match bundle.prekey { Some((id, key)) => Some((id.into(), public_key(&key, "prekey")?)), None => None, @@ -334,7 +348,7 @@ impl SignalOps { bundle.signed_prekey_signature, IdentityKey::new(public_key(&bundle.identity_key, "identity key")?), )?; - let mut sessions = self.stores().await; + let mut sessions = self.stores_over(staged.clone()).await; let mut identities = sessions.clone(); process_prekey_bundle( address, diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs index 0f3d2475..8005d7bd 100644 --- a/wacore/signal-ops/src/outbound.rs +++ b/wacore/signal-ops/src/outbound.rs @@ -9,9 +9,11 @@ //! before ciphertext is published. use crate::error::OpsError; +use crate::ops::RemoteBundle; use crate::ops::{ServiceStore, SignalOps}; use std::collections::HashSet; use wacore::client::context::SendContextResolver; +use wacore::libsignal::protocol::{UsePQRatchet, process_prekey_bundle}; use wacore::libsignal::store::sender_key_name::SenderKeyName; use wacore::runtime::Runtime; use wacore::send::{ @@ -19,8 +21,9 @@ use wacore::send::{ SenderKeyDistributionPolicy, SignalStores, prepare_dm_stanza, prepare_group_stanza, retain_skdm_distribution_targets, }; +use wacore::send::{PairwiseRetryDestination, PairwiseRetryRequest, prepare_pairwise_retry_stanza}; use wacore::types::jid::JidExt as _; -use wacore::types::message::AddressingMode; +use wacore::types::message::{AddressingMode, EditAttribute}; use wacore_binary::{Jid, JidExt}; use wacore_recordstore::Namespace; use waproto::whatsapp as wa; @@ -72,6 +75,36 @@ pub struct PreparedGroupSend { pub phash: Option, } +/// Where a retried message goes, matching the client's retransmission routes. +#[derive(Debug, Clone)] +pub enum RetryRoute { + /// A direct message; `recipient` is the original chat user when the + /// requester is one of this account's own devices. + Direct { + recipient: Option, + }, + Group { + addressing_mode: AddressingMode, + }, + BroadcastList, +} + +/// A retry receipt the client received for a message this device sent. +#[derive(Debug, Clone)] +pub struct RetryRequest<'a> { + /// The chat the original went to (the recipient user, group or list). + pub chat: &'a Jid, + pub message_id: &'a str, + /// The device that asked, as addressed on the wire. + pub requester: &'a Jid, + /// The address to encrypt to (the requester's LID when known). + pub encryption_jid: &'a Jid, + pub route: RetryRoute, + pub retry_count: u8, + /// Keys the receipt carried, used to start a fresh session. + pub bundle: Option<&'a RemoteBundle>, +} + struct OwnIdentity { pn: Jid, lid: Option, @@ -423,4 +456,84 @@ impl SignalOps { .await?; Ok(()) } + + /// Re-encrypt a sent message for the device that asked for it. Without a + /// session and without keys in the receipt, a bundle is fetched through + /// the resolver, as the client does. Status messages are not handled here. + pub async fn resend( + &self, + resolver: &dyn SendContextResolver, + request: RetryRequest<'_>, + ) -> Result, OpsError> { + let message = self + .sent_message(request.chat, request.message_id) + .await? + .ok_or_else(|| OpsError::InvalidInput("no record of that sent message".into()))?; + let OwnIdentity { account, .. } = self.own_identity().await?; + let address = request.encryption_jid.to_protocol_address(); + + let lock = self.session_lock(); + let _guard = lock.lock().await; + let staged = std::sync::Arc::new(self.store().staged()); + if let Some(bundle) = request.bundle { + self.establish_into(&staged, &address, bundle).await?; + } else if !staged.has_session(address.as_str()).await? { + let fetched = resolver + .fetch_prekeys(std::slice::from_ref(request.encryption_jid)) + .await + .map_err(send_error)?; + let bundle = fetched.get(request.encryption_jid).ok_or_else(|| { + OpsError::Send("no session and no prekey bundle for the requester".into()) + })?; + let mut sessions = self.stores_over(staged.clone()).await; + let mut identities = sessions.clone(); + process_prekey_bundle( + &address, + &mut sessions, + &mut identities, + bundle, + &mut rand::make_rng::(), + UsePQRatchet::No, + ) + .await?; + } + + let destination = match request.route { + RetryRoute::Direct { recipient } => PairwiseRetryDestination::Direct { + to: request.requester.clone(), + recipient, + }, + RetryRoute::Group { addressing_mode } => PairwiseRetryDestination::Participant { + to: request.chat.clone(), + participant: request.requester.clone(), + addressing_mode: Some(addressing_mode), + }, + RetryRoute::BroadcastList => PairwiseRetryDestination::Participant { + to: request.chat.clone(), + participant: request.requester.clone(), + addressing_mode: None, + }, + }; + let mut sessions = self.stores_over(staged.clone()).await; + let mut identities = sessions.clone(); + let node = prepare_pairwise_retry_stanza( + &mut sessions, + &mut identities, + PairwiseRetryRequest { + destination, + encryption_jid: request.encryption_jid.clone(), + message: &message, + message_id: request.message_id.to_owned(), + retry_count: request.retry_count, + account: account.as_ref(), + edit: EditAttribute::infer_from_message(&message), + pre_encoded: None, + }, + ) + .await + .map_err(send_error)?; + staged.commit().await?; + wacore_binary::marshal(&node) + .map_err(|e| OpsError::Send(format!("stanza did not marshal: {e}"))) + } } diff --git a/wacore/signal-ops/tests/common/mod.rs b/wacore/signal-ops/tests/common/mod.rs new file mode 100644 index 00000000..d7a32247 --- /dev/null +++ b/wacore/signal-ops/tests/common/mod.rs @@ -0,0 +1,58 @@ +//! Helpers shared by the integration tests. +#![allow(dead_code)] + +/// A pair-success container as the primary phone builds it: the account key +/// signs our identity, and the whole blob is HMACed with the QR's ADV secret. +// Test-only protos with no waproto::codec helper; the repo allows this in tests. +#[allow(clippy::disallowed_methods)] +pub fn pair_success_container( + device_identity: [u8; 32], + adv_secret: &[u8; 32], + key_index: u32, +) -> (Vec, wacore::libsignal::protocol::KeyPair) { + use buffa::Message; + use hmac::{KeyInit, Mac}; + use wacore::libsignal::protocol::{KeyPair, PublicKey}; + use waproto::whatsapp as wa; + + let mut rng = rand::make_rng::(); + let account = KeyPair::generate(&mut rng); + // Only the public half is read when no device prefix is requested. + let device = KeyPair::new( + PublicKey::from_djb_public_key_bytes(&device_identity).unwrap(), + KeyPair::generate(&mut rng).private_key, + ); + let details = wa::ADVDeviceIdentity { + raw_id: Some(1), + timestamp: Some(0), + key_index: Some(key_index), + account_type: Some(wa::ADVEncryptionType::E2EE), + device_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + let signed = wacore::adv::test_util::signed_identity( + &account, + &device, + &details, + wacore::adv::test_util::account_prefix(Some(wa::ADVEncryptionType::E2EE)), + None, + true, + ) + .encode_to_vec(); + let mut mac = as KeyInit>::new_from_slice(adv_secret).unwrap(); + mac.update(&signed); + let container = wa::ADVSignedDeviceIdentityHMAC { + details: Some(signed), + hmac: Some(mac.finalize().into_bytes().to_vec()), + account_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + (container, account) +} + +/// Pair `ops` the way a real primary would, so it holds an account identity. +pub async fn pair(ops: &wacore_signal_ops::SignalOps) { + let identity = ops.public_identity().await; + let (container, _) = pair_success_container(identity.identity_key, &identity.adv_secret, 1); + ops.sign_pairing(&container).await.unwrap(); +} diff --git a/wacore/signal-ops/tests/end_to_end.rs b/wacore/signal-ops/tests/end_to_end.rs index 5f2d2d00..c7e101ef 100644 --- a/wacore/signal-ops/tests/end_to_end.rs +++ b/wacore/signal-ops/tests/end_to_end.rs @@ -8,6 +8,8 @@ use wacore::store::traits::SignalStore; use wacore_recordstore::{ AesGcmSealer, LeaseStore, MemoryRecordStore, Namespace, RecordSignalStore, RecordStore, }; +mod common; +use common::pair_success_container; use wacore_signal_ops::{EncKind, OpsError, RemoteBundle, SignalOps}; type Store = RecordSignalStore; @@ -223,55 +225,6 @@ fn reopening_a_scope_restores_the_same_device() { ); } -/// A pair-success container as the primary phone builds it: the account key -/// signs our identity, and the whole blob is HMACed with the QR's ADV secret. -// Test-only protos with no waproto::codec helper; the repo allows this in tests. -#[allow(clippy::disallowed_methods)] -fn pair_success_container( - device_identity: [u8; 32], - adv_secret: &[u8; 32], - key_index: u32, -) -> (Vec, wacore::libsignal::protocol::KeyPair) { - use buffa::Message; - use hmac::{KeyInit, Mac}; - use wacore::libsignal::protocol::{KeyPair, PublicKey}; - use waproto::whatsapp as wa; - - let mut rng = rand::make_rng::(); - let account = KeyPair::generate(&mut rng); - // Only the public half is read when no device prefix is requested. - let device = KeyPair::new( - PublicKey::from_djb_public_key_bytes(&device_identity).unwrap(), - KeyPair::generate(&mut rng).private_key, - ); - let details = wa::ADVDeviceIdentity { - raw_id: Some(1), - timestamp: Some(0), - key_index: Some(key_index), - account_type: Some(wa::ADVEncryptionType::E2EE), - device_type: Some(wa::ADVEncryptionType::E2EE), - } - .encode_to_vec(); - let signed = wacore::adv::test_util::signed_identity( - &account, - &device, - &details, - wacore::adv::test_util::account_prefix(Some(wa::ADVEncryptionType::E2EE)), - None, - true, - ) - .encode_to_vec(); - let mut mac = as KeyInit>::new_from_slice(adv_secret).unwrap(); - mac.update(&signed); - let container = wa::ADVSignedDeviceIdentityHMAC { - details: Some(signed), - hmac: Some(mac.finalize().into_bytes().to_vec()), - account_type: Some(wa::ADVEncryptionType::E2EE), - } - .encode_to_vec(); - (container, account) -} - #[test] fn pairing_signs_a_valid_container_with_the_device_prefix() { let bob = new_device("bob-pairing-ok"); diff --git a/wacore/signal-ops/tests/send.rs b/wacore/signal-ops/tests/send.rs index e76dc9b9..ec3242f7 100644 --- a/wacore/signal-ops/tests/send.rs +++ b/wacore/signal-ops/tests/send.rs @@ -14,6 +14,7 @@ use wacore::runtime::{AbortHandle, Runtime}; use wacore::types::jid::JidExt as _; use wacore_binary::{Jid, NodeContent}; use wacore_recordstore::{AesGcmSealer, LeaseStore, MemoryRecordStore, RecordSignalStore}; +mod common; use wacore_signal_ops::{ReceiveKind, ReceiveRequest, Received, SignalOps}; use waproto::whatsapp as wa; @@ -347,3 +348,142 @@ async fn a_group_send_distributes_the_sender_key_once_then_reuses_it() { .unwrap(); assert_eq!(third.distribution_targets, vec![bob_jid.clone()]); } + +/// The first `` anywhere in the stanza. +fn first_enc(stanza: &[u8]) -> (String, u8, Vec) { + fn find(node: &wacore_binary::Node) -> Option<&wacore_binary::Node> { + if node.tag == "enc" { + return Some(node); + } + node.children()?.iter().find_map(find) + } + enc_parts(find(&parse(stanza)).expect("enc")) +} + +fn remote_bundle_from(bundle: &PreKeyBundle) -> wacore_signal_ops::RemoteBundle { + let key = |k: &PublicKey| -> [u8; 32] { k.public_key_bytes().try_into().unwrap() }; + wacore_signal_ops::RemoteBundle { + registration_id: bundle.registration_id().unwrap(), + device_id: bundle.device_id().unwrap().into(), + identity_key: key(bundle.identity_key().unwrap().public_key()), + signed_prekey_id: bundle.signed_pre_key_id().unwrap().into(), + signed_prekey: key(&bundle.signed_pre_key_public().unwrap()), + signed_prekey_signature: bundle + .signed_pre_key_signature() + .unwrap() + .try_into() + .unwrap(), + prekey: bundle + .pre_key_id() + .unwrap() + .map(|id| (id.into(), key(&bundle.pre_key_public().unwrap().unwrap()))), + } +} + +/// Decrypt the first `` of a stanza from `from` and return its text. +async fn deliver(ops: &SignalOps, from: &Jid, stanza: Vec) -> Option { + let (kind, version, ciphertext) = first_enc(&stanza); + let sender = from.to_protocol_address(); + let chat = from.to_non_ad().to_string(); + let received = ops + .receive(ReceiveRequest { + chat: &chat, + sender: &sender, + kind: if kind == "pkmsg" { + ReceiveKind::PreKey + } else { + ReceiveKind::Message + }, + ciphertext: &ciphertext, + padding_version: version, + is_from_me: false, + }) + .await + .unwrap(); + let Received::Message(message) = received else { + panic!("expected a message") + }; + message.content.message.conversation +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_retry_receipt_gets_the_message_re_encrypted() { + use wacore_signal_ops::{RetryRequest, RetryRoute}; + + let alice_jid: Jid = "111:1@s.whatsapp.net".parse().unwrap(); + let bob_jid: Jid = "222:1@s.whatsapp.net".parse().unwrap(); + let alice = device("alice-retry", "111:1@s.whatsapp.net").await; + // A retry pkmsg must carry the device identity, which pairing provides. + common::pair(&alice).await; + let bob = device("bob-retry", "222:1@s.whatsapp.net").await; + let client = FakeClient { + devices: vec![bob_jid.clone()], + bundles: HashMap::from([(bob_jid.clone(), bundle_for(&bob).await)]), + group: None, + }; + let to = bob_jid.to_non_ad(); + // Bob never processes the original. + alice + .send_direct(&TokioRuntime, &client, &to, &text("lost once"), "R1") + .await + .unwrap(); + + let retried = alice + .resend( + &client, + RetryRequest { + chat: &to, + message_id: "R1", + requester: &bob_jid, + encryption_jid: &bob_jid, + route: RetryRoute::Direct { recipient: None }, + retry_count: 1, + bundle: None, + }, + ) + .await + .unwrap(); + assert_eq!( + deliver(&bob, &alice_jid, retried).await.as_deref(), + Some("lost once") + ); + + // Bob's device was reset: new keys arrive with the next retry receipt. + let fresh_bob = device("bob-retry-fresh", "222:1@s.whatsapp.net").await; + let fresh_bundle = remote_bundle_from(&bundle_for(&fresh_bob).await); + let retried = alice + .resend( + &client, + RetryRequest { + chat: &to, + message_id: "R1", + requester: &bob_jid, + encryption_jid: &bob_jid, + route: RetryRoute::Direct { recipient: None }, + retry_count: 2, + bundle: Some(&fresh_bundle), + }, + ) + .await + .unwrap(); + assert_eq!( + deliver(&fresh_bob, &alice_jid, retried).await.as_deref(), + Some("lost once") + ); + + let unknown = alice + .resend( + &client, + RetryRequest { + chat: &to, + message_id: "NOPE", + requester: &bob_jid, + encryption_jid: &bob_jid, + route: RetryRoute::Direct { recipient: None }, + retry_count: 1, + bundle: None, + }, + ) + .await; + assert!(unknown.is_err()); +} From 817742811efe7ab168ad09b0c9d1643b280069eb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 04:45:56 +0300 Subject: [PATCH 09/17] feat(signal-wasm): build direct and group sends in the page The bindings expose setOwnJids, sendDirect, sendGroup, markSenderKeyDistributed and forgetSenderKeyDevices. The page supplies a SendResolver that relays device-list, prekey and group-routing queries to the WhatsApp client; a PageRuntime runs wacore's spawned work on the JS event loop and times out through setTimeout. Messages travel as encoded waE2E.Message bytes and stanzas come back marshaled, ready to transmit. RemoteBundle::to_prekey_bundle is now public so the bindings validate JS bundles the same way the service does. --- Cargo.lock | 2 + wacore/signal-ops/src/ops.rs | 33 ++-- wacore/signal-wasm/Cargo.toml | 2 + wacore/signal-wasm/src/convert.rs | 18 ++ wacore/signal-wasm/src/lib.rs | 129 +++++++++++++++ wacore/signal-wasm/src/send.rs | 221 +++++++++++++++++++++++++ wacore/signal-wasm/tests/node.test.mjs | 50 ++++++ 7 files changed, 442 insertions(+), 13 deletions(-) create mode 100644 wacore/signal-wasm/src/send.rs diff --git a/Cargo.lock b/Cargo.lock index 3c74796b..0cb2fda9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5362,11 +5362,13 @@ dependencies = [ name = "wacore-signal-wasm" version = "0.1.0" dependencies = [ + "anyhow", "async-trait", "getrandom 0.4.3", "js-sys", "send_wrapper", "wacore", + "wacore-binary", "wacore-recordstore", "wacore-signal-ops", "waproto", diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 8858d182..2f699441 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -119,6 +119,25 @@ pub struct RemoteBundle { pub prekey: Option<(u32, [u8; 32])>, } +impl RemoteBundle { + /// The libsignal bundle, validating every key. + pub fn to_prekey_bundle(&self) -> Result { + let prekey = match self.prekey { + Some((id, key)) => Some((id.into(), public_key(&key, "prekey")?)), + None => None, + }; + Ok(PreKeyBundle::new( + self.registration_id, + DeviceId::from(self.device_id), + prekey, + self.signed_prekey_id.into(), + public_key(&self.signed_prekey, "signed prekey")?, + self.signed_prekey_signature, + IdentityKey::new(public_key(&self.identity_key, "identity key")?), + )?) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PairingSignature { /// `ADVSignedDeviceIdentity` with the device signature added, ready for the @@ -335,19 +354,7 @@ impl SignalOps { address: &ProtocolAddress, bundle: &RemoteBundle, ) -> Result<(), OpsError> { - let prekey = match bundle.prekey { - Some((id, key)) => Some((id.into(), public_key(&key, "prekey")?)), - None => None, - }; - let bundle = PreKeyBundle::new( - bundle.registration_id, - DeviceId::from(bundle.device_id), - prekey, - bundle.signed_prekey_id.into(), - public_key(&bundle.signed_prekey, "signed prekey")?, - bundle.signed_prekey_signature, - IdentityKey::new(public_key(&bundle.identity_key, "identity key")?), - )?; + let bundle = bundle.to_prekey_bundle()?; let mut sessions = self.stores_over(staged.clone()).await; let mut identities = sessions.clone(); process_prekey_bundle( diff --git a/wacore/signal-wasm/Cargo.toml b/wacore/signal-wasm/Cargo.toml index 241e3811..add98a62 100644 --- a/wacore/signal-wasm/Cargo.toml +++ b/wacore/signal-wasm/Cargo.toml @@ -15,10 +15,12 @@ crate-type = ["cdylib", "rlib"] # The bindings exist only on wasm32; on other targets the crate is empty, so a # native `cargo check --workspace` stays green without JS types. [target.'cfg(target_arch = "wasm32")'.dependencies] +anyhow = { workspace = true } async-trait = { workspace = true } js-sys = "0.3.105" send_wrapper = "0.6" wacore = { workspace = true } +wacore-binary = { workspace = true } wacore-recordstore = { path = "../recordstore" } wacore-signal-ops = { path = "../signal-ops" } wasm-bindgen = "0.2.128" diff --git a/wacore/signal-wasm/src/convert.rs b/wacore/signal-wasm/src/convert.rs index 19b8f8cf..b593340d 100644 --- a/wacore/signal-wasm/src/convert.rs +++ b/wacore/signal-wasm/src/convert.rs @@ -207,3 +207,21 @@ pub fn to_lease(value: &JsValue) -> Result { expires_at_ms, }) } + +pub fn jid(value: &str) -> Result { + value + .parse() + .map_err(|_| invalid(&format!("{value} is not a JID"))) +} + +pub fn jid_list(jids: &[wacore_binary::Jid]) -> JsValue { + jids.iter() + .map(|j| JsValue::from_str(&j.to_string())) + .collect::() + .into() +} + +pub fn message(bytes: &[u8]) -> Result { + waproto::codec::message_decode(bytes) + .map_err(|e| invalid(&format!("message is not an encoded waE2E.Message: {e}"))) +} diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index 259ee0f9..2e970701 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -7,6 +7,7 @@ mod adapters; mod convert; +mod send; use adapters::{JsRecords, JsSealerAdapter}; use convert::{ @@ -460,6 +461,134 @@ impl SignalDevice { }) } + /// Record this device's own JIDs after pair success. Sends need them. + #[wasm_bindgen(js_name = setOwnJids)] + pub fn set_own_jids(&self, pn: String, lid: Option) -> Promise { + let inner = self.inner.clone(); + promise(async move { + inner + .ops + .set_own_jids(&pn, lid.as_deref()) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + /// Build a direct-message stanza from an encoded `waE2E.Message`. + /// Resolves `{ stanza, unreachedDevices, phash }`; transmit `stanza` + /// through the WhatsApp client unchanged. + #[wasm_bindgen(js_name = sendDirect)] + pub fn send_direct( + &self, + resolver: send::SendResolver, + to: String, + message: Vec, + message_id: String, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let to = convert::jid(&to)?; + let message = convert::message(&message)?; + let resolver = send::JsResolver::new(resolver); + let sent = inner + .ops + .send_direct(&send::PageRuntime, &resolver, &to, &message, &message_id) + .await + .map_err(ops_error)?; + let out = Object::new(); + set(&out, "stanza", &Uint8Array::from(sent.stanza.as_slice())); + set( + &out, + "unreachedDevices", + &convert::jid_list(&sent.unreached_devices), + ); + set( + &out, + "phash", + &sent.phash.map_or(JsValue::NULL, |p| JsValue::from_str(&p)), + ); + Ok(out.into()) + }) + } + + /// Build a group stanza. Resolves `{ stanza, distributionTargets, + /// staleDeviceUsers, phash }`; after the server acks it, pass + /// `distributionTargets` to `markSenderKeyDistributed`. + #[wasm_bindgen(js_name = sendGroup)] + pub fn send_group( + &self, + resolver: send::SendResolver, + group: String, + message: Vec, + message_id: String, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let group = convert::jid(&group)?; + let message = convert::message(&message)?; + let resolver = send::JsResolver::new(resolver); + let sent = inner + .ops + .send_group(&send::PageRuntime, &resolver, &group, &message, &message_id) + .await + .map_err(ops_error)?; + let out = Object::new(); + set(&out, "stanza", &Uint8Array::from(sent.stanza.as_slice())); + set( + &out, + "distributionTargets", + &convert::jid_list(&sent.distribution_targets), + ); + let stale: js_sys::Array = sent + .stale_device_users + .iter() + .map(|u| JsValue::from_str(u)) + .collect(); + set(&out, "staleDeviceUsers", &stale); + set( + &out, + "phash", + &sent.phash.map_or(JsValue::NULL, |p| JsValue::from_str(&p)), + ); + Ok(out.into()) + }) + } + + /// Record that the server acked a group send carrying the sender key. + #[wasm_bindgen(js_name = markSenderKeyDistributed)] + pub fn mark_sender_key_distributed(&self, group: String, devices: Vec) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let group = convert::jid(&group)?; + let devices = devices + .iter() + .map(|d| convert::jid(d)) + .collect::, _>>()?; + inner + .ops + .mark_sender_key_distributed(&group, &devices) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + /// Make the next group send distribute the sender key again. + #[wasm_bindgen(js_name = forgetSenderKeyDevices)] + pub fn forget_sender_key_devices(&self, group: String) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let group = convert::jid(&group)?; + inner + .ops + .forget_sender_key_devices(&group) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + /// Verify the primary's pair-success container and add the device /// signature. Rejects with code "pairing_refused" and a `status`. #[wasm_bindgen(js_name = signPairing)] diff --git a/wacore/signal-wasm/src/send.rs b/wacore/signal-wasm/src/send.rs new file mode 100644 index 00000000..9cae2ecc --- /dev/null +++ b/wacore/signal-wasm/src/send.rs @@ -0,0 +1,221 @@ +//! Runtime and resolver for sends built in the page. +//! +//! wacore's stanza builders spawn per-device work and ask a +//! `SendContextResolver` for device lists and prekey bundles. In the page the +//! runtime is the JS event loop and the resolver is a JS object that relays to +//! the WhatsApp client (Polymorfa's runner). + +use crate::convert::remote_bundle; +use async_trait::async_trait; +use js_sys::{Array, Function, Object, Promise, Reflect}; +use send_wrapper::SendWrapper; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; +use wacore::client::context::{GroupRoutingInfo, SendContextResolver}; +use wacore::libsignal::protocol::PreKeyBundle; +use wacore::prekeys::PreKeyFetchOutcome; +use wacore::runtime::{AbortHandle, Runtime}; +use wacore::types::message::AddressingMode; +use wacore_binary::{CompactString, Jid}; +use wasm_bindgen::prelude::*; +use wasm_bindgen_futures::{JsFuture, spawn_local}; + +#[wasm_bindgen(typescript_custom_section)] +const TS_SEND_TYPES: &str = r#" +/** Answers the device and key queries a send needs, by relaying to the WhatsApp client. */ +export interface SendResolver { + /** Device JIDs for these user JIDs. */ + resolveDevices(jids: string[]): Promise; + /** Prekey bundles by device JID; omit devices without one. */ + fetchPrekeys(jids: string[]): Promise>; + /** Group members and addressing; `lidToPn` maps LID users to phone JIDs. */ + resolveGroup(jid: string): Promise<{ + participants: string[]; + addressingMode: "pn" | "lid"; + lidToPn?: Record; + }>; +} +"#; + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(typescript_type = "SendResolver")] + pub type SendResolver; + + #[wasm_bindgen(method, catch, js_name = resolveDevices)] + fn resolve_devices(this: &SendResolver, jids: Array) -> Result; + + #[wasm_bindgen(method, catch, js_name = fetchPrekeys)] + fn fetch_prekeys(this: &SendResolver, jids: Array) -> Result; + + #[wasm_bindgen(method, catch, js_name = resolveGroup)] + fn resolve_group(this: &SendResolver, jid: &str) -> Result; +} + +/// Runs wacore's tasks on the page's event loop. +pub struct PageRuntime; + +fn set_timeout(ms: f64) -> Promise { + Promise::new(&mut |resolve, _reject| { + let set_timeout = Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout")) + .ok() + .and_then(|f| f.dyn_into::().ok()); + match set_timeout { + Some(f) => { + let _ = f.call2(&JsValue::NULL, &resolve, &JsValue::from_f64(ms)); + } + // No timer in this host: resolve at once rather than hang. + None => { + let _ = resolve.call0(&JsValue::NULL); + } + } + }) +} + +impl Runtime for PageRuntime { + fn spawn(&self, future: Pin + 'static>>) -> AbortHandle { + spawn_local(future); + AbortHandle::noop() + } + + fn sleep(&self, duration: Duration) -> Pin>> { + let promise = set_timeout(duration.as_millis() as f64); + Box::pin(async move { + let _ = JsFuture::from(promise).await; + }) + } + + fn spawn_blocking(&self, f: Box) -> Pin>> { + Box::pin(async move { f() }) + } + + fn yield_now(&self) -> Option>>> { + None + } +} + +/// A JS `SendResolver` as wacore's resolver. Single-threaded, so the handle is +/// wrapped only to satisfy the trait's `Send + Sync` bounds. +pub struct JsResolver(SendWrapper); + +impl JsResolver { + pub fn new(resolver: SendResolver) -> Self { + Self(SendWrapper::new(resolver)) + } +} + +fn js_error(context: &str, error: JsValue) -> anyhow::Error { + let message = Reflect::get(&error, &JsValue::from_str("message")) + .ok() + .and_then(|m| m.as_string()) + .or_else(|| error.as_string()) + .unwrap_or_else(|| "JavaScript error".to_owned()); + anyhow::anyhow!("{context}: {message}") +} + +async fn call(context: &str, call: Result) -> anyhow::Result { + let promise = call.map_err(|e| js_error(context, e))?; + JsFuture::from(promise) + .await + .map_err(|e| js_error(context, e)) +} + +fn jid_array(jids: &[Jid]) -> Array { + jids.iter() + .map(|jid| JsValue::from_str(&jid.to_string())) + .collect() +} + +fn parse_jid(value: &JsValue, context: &str) -> anyhow::Result { + value + .as_string() + .ok_or_else(|| anyhow::anyhow!("{context}: expected a JID string"))? + .parse() + .map_err(|_| anyhow::anyhow!("{context}: not a JID")) +} + +#[async_trait(?Send)] +impl SendContextResolver for JsResolver { + async fn resolve_devices(&self, jids: &[Jid]) -> anyhow::Result> { + let value = call("resolveDevices", self.0.resolve_devices(jid_array(jids))).await?; + let list: Array = value + .dyn_into() + .map_err(|_| anyhow::anyhow!("resolveDevices must resolve to an array"))?; + list.iter() + .map(|item| parse_jid(&item, "resolveDevices")) + .collect() + } + + async fn fetch_prekeys(&self, jids: &[Jid]) -> anyhow::Result> { + let value = call("fetchPrekeys", self.0.fetch_prekeys(jid_array(jids))).await?; + let object: Object = value + .dyn_into() + .map_err(|_| anyhow::anyhow!("fetchPrekeys must resolve to an object"))?; + let mut bundles = HashMap::new(); + for entry in Object::entries(&object).iter() { + let pair: Array = entry.unchecked_into(); + let jid = parse_jid(&pair.get(0), "fetchPrekeys")?; + let bundle = remote_bundle(&pair.get(1)) + .map_err(|e| js_error("fetchPrekeys bundle", e))? + .to_prekey_bundle() + .map_err(|e| anyhow::anyhow!("fetchPrekeys bundle: {e}"))?; + bundles.insert(jid, bundle); + } + Ok(bundles) + } + + async fn fetch_prekeys_for_identity_check( + &self, + jids: &[Jid], + ) -> anyhow::Result { + Ok(PreKeyFetchOutcome { + bundles: self.fetch_prekeys(jids).await?, + rejected: Vec::new(), + }) + } + + async fn resolve_group_routing_info(&self, jid: &Jid) -> anyhow::Result> { + let value = call("resolveGroup", self.0.resolve_group(&jid.to_string())).await?; + let get = + |key: &str| Reflect::get(&value, &JsValue::from_str(key)).unwrap_or(JsValue::UNDEFINED); + let participants: Array = get("participants") + .dyn_into() + .map_err(|_| anyhow::anyhow!("resolveGroup: participants must be an array"))?; + let participants = participants + .iter() + .map(|p| parse_jid(&p, "resolveGroup participant")) + .collect::>>()?; + let mode = match get("addressingMode").as_string().as_deref() { + Some("lid") => AddressingMode::Lid, + Some("pn") => AddressingMode::Pn, + _ => anyhow::bail!("resolveGroup: addressingMode must be \"pn\" or \"lid\""), + }; + let lid_to_pn = get("lidToPn"); + if lid_to_pn.is_undefined() || lid_to_pn.is_null() { + return Ok(Arc::new(GroupRoutingInfo::new(participants, mode))); + } + let object: Object = lid_to_pn + .dyn_into() + .map_err(|_| anyhow::anyhow!("resolveGroup: lidToPn must be an object"))?; + let mut map = HashMap::new(); + for entry in Object::entries(&object).iter() { + let pair: Array = entry.unchecked_into(); + let lid_user = pair + .get(0) + .as_string() + .ok_or_else(|| anyhow::anyhow!("resolveGroup: lidToPn key must be a string"))?; + map.insert( + CompactString::from(lid_user), + parse_jid(&pair.get(1), "resolveGroup lidToPn")?, + ); + } + Ok(Arc::new(GroupRoutingInfo::with_lid_to_pn_map( + participants, + mode, + map, + ))) + } +} diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index 3f405bf3..9299da6e 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -277,3 +277,53 @@ test("receive buffers until delivered and recognises redelivery after", async () assert.equal(after.status, "already_delivered"); assert.equal(bob.backend.containsBytes(text("hello from js")), false); }); + +function conversation(body) { + const utf8 = text(body); + return Uint8Array.from([0x0a, utf8.length, ...utf8]); +} + +test("sends are built in the page and only ask the resolver for devices and keys", async () => { + const alice = await device("alice-send-js"); + const bob = await device("bob-send-js"); + await alice.signal.setOwnJids("111:1@s.whatsapp.net", "900111:1@lid"); + const bobDevice = "222:1@s.whatsapp.net"; + const bobBundle = await bundleFor(bob.signal); + const calls = { devices: [], prekeys: [] }; + const resolver = { + async resolveDevices(jids) { + calls.devices.push(jids); + return [bobDevice]; + }, + async fetchPrekeys(jids) { + calls.prekeys.push(jids); + return Object.fromEntries(jids.filter((j) => j === bobDevice).map((j) => [j, bobBundle])); + }, + async resolveGroup() { + return { + participants: ["111@s.whatsapp.net", "222@s.whatsapp.net"], + addressingMode: "pn", + }; + }, + }; + + const first = await alice.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("hi"), "J1"); + assert.ok(first.stanza instanceof Uint8Array && first.stanza.length > 0); + assert.deepEqual(first.unreachedDevices, []); + assert.deepEqual(calls.devices[0].sort(), ["111@s.whatsapp.net", "222@s.whatsapp.net"]); + assert.deepEqual(calls.prekeys, [[bobDevice]]); + + await alice.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("again"), "J2"); + assert.equal(calls.prekeys.length, 1, "an established session needs no new bundle"); + assert.equal(alice.backend.containsBytes(text("again")), false); + + const group = await alice.signal.sendGroup(resolver, "120363000000001@g.us", conversation("g"), "G1"); + assert.ok(group.stanza.length > 0); + assert.deepEqual(group.distributionTargets, [bobDevice]); + + const unpaired = await device("unpaired-js"); + await assert.rejects( + unpaired.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("x"), "J3"), + (error) => error.code === "invalid_input", + ); +}); From e3cc9ac1c7afdeb8743c46b393cc9eb11727223a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 05:01:21 +0300 Subject: [PATCH 10/17] fix: address review findings on the Signal service crates - recordstore: length-prefix every AAD component so no two (scope, namespace, key) slots seal alike; NUL-joined fields were ambiguous. - recordstore: add WriteOp::Insert (exclusive, fails the batch with RecordExists); SignalOps::create uses it, so concurrent initialisers cannot both write device keys. Covered by the conformance suite. - signal-ops: replace the device-wide lock with keyed locks on the state each operation writes (peer session, sender-key chain, group marks), taken in sorted order. Sends fix encryption addresses up front so the locks cover the sessions they write. A slow prekey fetch no longer blocks unrelated decrypts; process_sender_key_distribution now locks. - signal-ops: strip app-state key shares and requests from non-own senders out of the delivered message, not only the gated field. - signal-ops: a malformed sender-key distribution no longer aborts receive; the message commits and reports sender_key_rejected. - signal-ops: timestamp sent-message records and add prune_sent_messages. - signal-wasm: build protocol addresses with JidExt::to_protocol_address, release the lease when open fails, map insert/record_exists, and drop a rustdoc link that cannot resolve on native targets. --- wacore/recordstore/src/conformance.rs | 46 ++++++++++- wacore/recordstore/src/lib.rs | 3 +- wacore/recordstore/src/memory.rs | 19 ++++- wacore/recordstore/src/record.rs | 36 ++++++++- wacore/recordstore/src/signal.rs | 74 ++++++++++++++--- wacore/signal-ops/Cargo.toml | 2 +- wacore/signal-ops/src/inbound.rs | 60 +++++++++++--- wacore/signal-ops/src/ops.rs | 93 ++++++++++++++------- wacore/signal-ops/src/outbound.rs | 107 +++++++++++++++++++------ wacore/signal-ops/tests/receive.rs | 35 ++++++++ wacore/signal-ops/tests/send.rs | 98 ++++++++++++++++++++++ wacore/signal-wasm/src/adapters.rs | 33 +++++--- wacore/signal-wasm/src/convert.rs | 14 +++- wacore/signal-wasm/src/lib.rs | 42 ++++++---- wacore/signal-wasm/tests/node.test.mjs | 16 +++- 15 files changed, 560 insertions(+), 118 deletions(-) diff --git a/wacore/recordstore/src/conformance.rs b/wacore/recordstore/src/conformance.rs index 80881895..c042b6b7 100644 --- a/wacore/recordstore/src/conformance.rs +++ b/wacore/recordstore/src/conformance.rs @@ -5,7 +5,9 @@ //! scope with a random suffix, so the suite can share a database with other //! tests and with earlier runs. -use crate::record::{Fence, LeaseStore, Namespace, RecordStore, WriteOp, is_fence_lost}; +use crate::record::{ + Fence, LeaseStore, Namespace, RecordStore, WriteOp, is_fence_lost, is_record_exists, +}; use rand::Rng; const TTL: u64 = 10_000; @@ -21,6 +23,7 @@ pub async fn run_all(store: &S) { a_stale_batch_applies_nothing(store, &run).await; a_batch_applies_every_op(store, &run).await; update_never_inserts(store, &run).await; + insert_is_exclusive_and_atomic(store, &run).await; scan_orders_filters_and_limits(store, &run).await; scopes_and_namespaces_are_isolated(store, &run).await; get_many_returns_only_existing_keys(store, &run).await; @@ -383,3 +386,44 @@ pub async fn get_many_returns_only_existing_keys( "get_many_returns_only_existing_keys" ); } + +pub async fn insert_is_exclusive_and_atomic(store: &S, run: &str) { + let scope = scope(run, "insert"); + let fence = take(store, &scope, "a", 1_000).await; + let insert = |key: &str, value: &[u8]| WriteOp::Insert { + ns: Namespace::Device, + key: key.to_owned(), + value: value.to_vec(), + }; + store.write(&fence, &[insert("k", b"first")]).await.unwrap(); + let refused = store + .write( + &fence, + &[ + put(Namespace::Session, "side-effect", b"x"), + insert("k", b"second"), + ], + ) + .await; + assert!( + refused.as_ref().is_err_and(is_record_exists), + "insert_is_exclusive_and_atomic: second insert was not refused" + ); + assert_eq!( + store + .get(&scope, Namespace::Device, "k") + .await + .unwrap() + .as_deref(), + Some(&b"first"[..]), + "insert_is_exclusive_and_atomic: existing record changed" + ); + assert!( + store + .get(&scope, Namespace::Session, "side-effect") + .await + .unwrap() + .is_none(), + "insert_is_exclusive_and_atomic: refused batch applied another op" + ); +} diff --git a/wacore/recordstore/src/lib.rs b/wacore/recordstore/src/lib.rs index 5532acaa..aa53e92e 100644 --- a/wacore/recordstore/src/lib.rs +++ b/wacore/recordstore/src/lib.rs @@ -29,7 +29,8 @@ pub mod conformance; pub use memory::MemoryRecordStore; pub use record::{ - Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, WriteOp, is_fence_lost, + Fence, FenceLost, Lease, LeaseStore, Namespace, RecordExists, RecordStore, WriteOp, + is_fence_lost, is_record_exists, }; pub use seal::{AesGcmSealer, SealError, Sealer}; pub use signal::RecordSignalStore; diff --git a/wacore/recordstore/src/memory.rs b/wacore/recordstore/src/memory.rs index fbc42351..3383a4a0 100644 --- a/wacore/recordstore/src/memory.rs +++ b/wacore/recordstore/src/memory.rs @@ -1,4 +1,6 @@ -use crate::record::{Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, WriteOp}; +use crate::record::{ + Fence, FenceLost, Lease, LeaseStore, Namespace, RecordExists, RecordStore, WriteOp, +}; use async_trait::async_trait; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -82,9 +84,22 @@ impl RecordStore for MemoryRecordStore { .into_store_error()); } // Validation happens before any mutation, so the batch stays atomic. + for op in ops { + if let WriteOp::Insert { ns, key, .. } = op + && state + .records + .contains_key(&(fence.scope.clone(), *ns, key.clone())) + { + return Err(RecordExists { + ns: *ns, + key: key.clone(), + } + .into_store_error()); + } + } for op in ops { match op { - WriteOp::Put { ns, key, value } => { + WriteOp::Put { ns, key, value } | WriteOp::Insert { ns, key, value } => { state .records .insert((fence.scope.clone(), *ns, key.clone()), value.clone()); diff --git a/wacore/recordstore/src/record.rs b/wacore/recordstore/src/record.rs index 8fe693d4..d0591e1c 100644 --- a/wacore/recordstore/src/record.rs +++ b/wacore/recordstore/src/record.rs @@ -84,6 +84,13 @@ pub enum WriteOp { }, /// Remove; a missing key is a no-op. Delete { ns: Namespace, key: String }, + /// Insert only if the key is absent. If it exists, the whole batch fails + /// with [`RecordExists`] and nothing is applied. + Insert { + ns: Namespace, + key: String, + value: Vec, + }, } /// The write was refused because `fence` no longer matches the scope's lease. @@ -100,6 +107,32 @@ impl FenceLost { } } +/// An [`WriteOp::Insert`] found its key already present; nothing was applied. +#[derive(Debug, Clone, thiserror::Error)] +#[error("{ns:?} record {key} already exists")] +pub struct RecordExists { + pub ns: Namespace, + pub key: String, +} + +impl RecordExists { + pub fn into_store_error(self) -> StoreError { + StoreError::Database(Box::new(self)) + } +} + +/// Whether `error` (or any error it wraps) is a [`RecordExists`]. +pub fn is_record_exists(error: &StoreError) -> bool { + let mut layer: Option<&(dyn std::error::Error + 'static)> = Some(error); + while let Some(current) = layer { + if current.is::() { + return true; + } + layer = current.source(); + } + false +} + /// Whether `error` (or any error it wraps) is a [`FenceLost`]. A node that /// sees this must stop serving the scope; retrying cannot succeed. pub fn is_fence_lost(error: &StoreError) -> bool { @@ -146,7 +179,8 @@ pub trait RecordStore: Send + Sync { /// Apply every op or none, and only while `fence.generation` is the /// scope's current lease generation. Otherwise return - /// [`FenceLost::into_store_error`] and apply nothing. + /// [`FenceLost::into_store_error`] and apply nothing. An `Insert` whose key + /// exists fails the batch with [`RecordExists::into_store_error`]. async fn write(&self, fence: &Fence, ops: &[WriteOp]) -> Result<()>; } diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 56fec577..69d86c66 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -27,6 +27,7 @@ const AUX_NAMESPACES: [Namespace; 4] = [ #[derive(Debug, Clone)] enum PlainOp { Put(Namespace, String, Vec), + Insert(Namespace, String, Vec), Update(Namespace, String, Vec), Delete(Namespace, String), } @@ -35,6 +36,8 @@ enum PlainOp { #[derive(Debug, Clone)] enum Staged { Put(Vec), + /// Applied only if the key is still absent when the batch commits. + Insert(Vec), /// Applied only if the key still exists when the batch commits. Update(Vec), Delete, @@ -96,6 +99,11 @@ impl RecordSignalStore { ns, key, }, + Staged::Insert(plaintext) => WriteOp::Insert { + value: self.seal(ns, &key, &plaintext).await?, + ns, + key, + }, Staged::Update(plaintext) => WriteOp::Update { value: self.seal(ns, &key, &plaintext).await?, ns, @@ -114,18 +122,21 @@ impl RecordSignalStore { &self.fence.scope } + /// Authenticated data binding a value to its slot. Every component is + /// length-prefixed so no two (scope, namespace, key) triples encode alike; + /// scopes and keys may contain any byte, including NUL. fn aad(&self, ns: Namespace, key: &str) -> Vec { - let scope = self.scope().as_bytes(); - let tag = ns.tag().as_bytes(); let mut aad = - Vec::with_capacity(AAD_DOMAIN.len() + scope.len() + tag.len() + key.len() + 3); - aad.extend_from_slice(AAD_DOMAIN); - aad.push(0); - aad.extend_from_slice(scope); - aad.push(0); - aad.extend_from_slice(tag); - aad.push(0); - aad.extend_from_slice(key.as_bytes()); + Vec::with_capacity(AAD_DOMAIN.len() + 12 + self.scope().len() + key.len() + 16); + for part in [ + AAD_DOMAIN, + self.scope().as_bytes(), + ns.tag().as_bytes(), + key.as_bytes(), + ] { + aad.extend_from_slice(&(part.len() as u32).to_be_bytes()); + aad.extend_from_slice(part); + } aad } @@ -153,7 +164,7 @@ impl RecordSignalStore { async fn load(&self, ns: Namespace, key: &str) -> Result>> { match self.pending(ns, key)? { - Some(Staged::Put(v) | Staged::Update(v)) => return Ok(Some(v)), + Some(Staged::Put(v) | Staged::Update(v) | Staged::Insert(v)) => return Ok(Some(v)), Some(Staged::Delete) => return Ok(None), None => {} } @@ -168,7 +179,9 @@ impl RecordSignalStore { let mut from_store = Vec::with_capacity(keys.len()); for key in keys { match self.pending(ns, key)? { - Some(Staged::Put(v) | Staged::Update(v)) => opened.push(((*key).to_owned(), v)), + Some(Staged::Put(v) | Staged::Update(v) | Staged::Insert(v)) => { + opened.push(((*key).to_owned(), v)) + } Some(Staged::Delete) => {} None => from_store.push(*key), } @@ -200,7 +213,7 @@ impl RecordSignalStore { continue; } match change { - Staged::Put(_) => { + Staged::Put(_) | Staged::Insert(_) => { keys.insert(key.clone()); } Staged::Delete => { @@ -225,10 +238,12 @@ impl RecordSignalStore { for op in ops { let (slot, change) = match op { PlainOp::Put(ns, key, v) => ((ns, key), Staged::Put(v)), + PlainOp::Insert(ns, key, v) => ((ns, key), Staged::Insert(v)), PlainOp::Update(ns, key, v) => { // Updating a key this view already wrote keeps it a Put. let staged = match overlay.get(&(ns, key.clone())) { Some(Staged::Put(_)) => Staged::Put(v), + Some(Staged::Insert(_)) => Staged::Insert(v), Some(Staged::Delete) => continue, _ => Staged::Update(v), }; @@ -248,6 +263,11 @@ impl RecordSignalStore { ns, key, }, + PlainOp::Insert(ns, key, v) => WriteOp::Insert { + value: self.seal(ns, &key, &v).await?, + ns, + key, + }, PlainOp::Update(ns, key, v) => WriteOp::Update { value: self.seal(ns, &key, &v).await?, ns, @@ -320,6 +340,18 @@ impl RecordSignalStore { self.load(Namespace::Device, DEVICE_KEY).await } + /// Write the local device's first key blob. Fails with + /// [`crate::RecordExists`] if the scope already has one, so two concurrent + /// initialisers cannot both succeed. + pub async fn create_device(&self, blob: &[u8]) -> Result<()> { + self.apply(vec![PlainOp::Insert( + Namespace::Device, + DEVICE_KEY.to_owned(), + blob.to_vec(), + )]) + .await + } + /// Replace the local device's key blob, fenced like every other write. pub async fn save_device(&self, blob: &[u8]) -> Result<()> { self.put_one(Namespace::Device, DEVICE_KEY, blob).await @@ -727,6 +759,22 @@ mod tests { ); } + #[test] + fn slots_with_shifted_separators_get_distinct_aad() { + let records = Arc::new(MemoryRecordStore::new()); + let sealer = Arc::new(AesGcmSealer::new(&[3; 32])); + let fence = |scope: &str| Fence { + scope: Arc::from(scope), + generation: 1, + }; + let a = RecordSignalStore::new(records.clone(), sealer.clone(), fence("a")); + let b = RecordSignalStore::new(records, sealer, fence("a\0session\0b")); + assert_ne!( + a.aad(Namespace::Session, "b\0session\0c"), + b.aad(Namespace::Session, "c") + ); + } + #[test] fn values_are_sealed_at_rest() { let (records, store) = setup(); diff --git a/wacore/signal-ops/Cargo.toml b/wacore/signal-ops/Cargo.toml index b4215047..6da1cab0 100644 --- a/wacore/signal-ops/Cargo.toml +++ b/wacore/signal-ops/Cargo.toml @@ -25,7 +25,7 @@ waproto = { workspace = true } [dev-dependencies] anyhow = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } buffa = { workspace = true } futures = { workspace = true, features = ["executor"] } hmac = { workspace = true } diff --git a/wacore/signal-ops/src/inbound.rs b/wacore/signal-ops/src/inbound.rs index e408c82a..c6705c95 100644 --- a/wacore/signal-ops/src/inbound.rs +++ b/wacore/signal-ops/src/inbound.rs @@ -7,7 +7,7 @@ //! buffered result; once delivered it returns [`Received::AlreadyDelivered`]. use crate::error::OpsError; -use crate::ops::{EncKind, ServiceStore, SignalOps}; +use crate::ops::{EncKind, ServiceStore, SignalOps, chain_key, session_key}; use sha2::{Digest, Sha256}; use std::sync::Arc; use wacore::libsignal::protocol::ProtocolAddress; @@ -74,6 +74,10 @@ pub struct ReceivedMessage { pub identity_changed: bool, /// Served from the buffer after an earlier decrypt was not acknowledged. pub redelivered: bool, + /// The message carried a sender-key distribution that could not be + /// stored. The rest of the message is still delivered; later group + /// messages from this sender fail until a valid distribution arrives. + pub sender_key_rejected: bool, /// An app-state key share, only when sent by one of this account's own /// devices (WA Web's `isMeAccount` gate). The one piece of content the /// Polymorfa client receives. @@ -150,8 +154,25 @@ fn classify( padding_version: u8, is_from_me: bool, ) -> Result { - process_decrypted_plaintext(padded, padding_version, is_from_me) - .map_err(|e| OpsError::InvalidInput(format!("plaintext did not decode: {e}"))) + let mut content = process_decrypted_plaintext(padded, padding_version, is_from_me) + .map_err(|e| OpsError::InvalidInput(format!("plaintext did not decode: {e}")))?; + if !is_from_me { + strip_app_state_keys(&mut content); + } + Ok(content) +} + +/// App-state key shares and requests are only honoured from this account's +/// own devices (WA Web's `isMeAccount` gate). From anyone else they are +/// removed everywhere, so they cannot reach the app through the message. +fn strip_app_state_keys(content: &mut DecryptedMessageResult) { + if let Some(protocol) = content.message.protocol_message.as_option_mut() { + protocol.app_state_sync_key_share = Default::default(); + protocol.app_state_sync_key_request = Default::default(); + } + if let Some(info) = content.protocol_message.as_mut() { + info.app_state_sync_key_share = None; + } } fn key_share( @@ -172,8 +193,14 @@ impl SignalOps { /// distributions inside it are stored in the same batch as the decrypt. pub async fn receive(&self, request: ReceiveRequest<'_>) -> Result { let receipt_key = receipt_key(&request); - let lock = self.session_lock(); - let _guard = lock.lock().await; + // Pairwise payloads can carry a sender-key distribution for this chat, + // so they also lock the sender's chain. + let chain = chain_key(request.chat, request.sender.as_str()); + let keys = match request.kind { + ReceiveKind::SenderKey => vec![chain], + _ => vec![session_key(request.sender), chain], + }; + let _held = self.lock_keys(keys).await; if let Some(bytes) = self .store() @@ -195,6 +222,7 @@ impl SignalOps { content, identity_changed: entry.identity_changed, redelivered: true, + sender_key_rejected: false, }))); } @@ -236,16 +264,23 @@ impl SignalOps { } }; + // A bad distribution must not block the message carrying it: failing + // here would discard the ratchet advance, and every redelivery would + // fail the same way. The client logs and continues the same way. + let mut sender_key_rejected = false; if let Some(skdm) = &content.skdm && let Some(axolotl) = &skdm.axolotl_sender_key_distribution_message + && self + .process_sender_key_distribution_into( + &staged, + request.chat, + request.sender, + axolotl, + ) + .await + .is_err() { - self.process_sender_key_distribution_into( - &staged, - request.chat, - request.sender, - axolotl, - ) - .await?; + sender_key_rejected = true; } let entry = BufferEntry { @@ -266,6 +301,7 @@ impl SignalOps { content, identity_changed, redelivered: false, + sender_key_rejected, }))) } diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 2f699441..0e60ef82 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -29,6 +29,9 @@ use wacore_recordstore::{Namespace, RecordSignalStore, RecordStore, Sealer}; pub trait ServiceStore: SignalStore + Sized + 'static { async fn load_device_keys(&self) -> StoreResult>>; async fn save_device_keys(&self, blob: &[u8]) -> StoreResult<()>; + /// Write the first device key blob; fails with `RecordExists` if one is + /// already stored. + async fn create_device_keys(&self, blob: &[u8]) -> StoreResult<()>; /// A view whose writes are held until [`Self::commit`]. fn staged(&self) -> Self; /// Apply a staged view's writes as one fenced batch. @@ -50,6 +53,10 @@ impl ServiceStore for RecordSigna self.save_device(blob).await } + async fn create_device_keys(&self, blob: &[u8]) -> StoreResult<()> { + self.create_device(blob).await + } + fn staged(&self) -> Self { RecordSignalStore::staged(self) } @@ -146,18 +153,38 @@ pub struct PairingSignature { pub key_index: u32, } +/// Locks held by one operation. +pub(crate) type Held = Vec>; + +/// Lock key for one peer device's session and identity. +pub(crate) fn session_key(address: &ProtocolAddress) -> String { + format!("s:{}", address.as_str()) +} + +/// Lock key for one sender-key chain. +pub(crate) fn chain_key(group: &str, sender: &str) -> String { + format!("k:{group}\u{0}{sender}") +} + +/// Lock key for a group's sender-key distribution marks. +pub(crate) fn group_key(group: &str) -> String { + format!("g:{group}") +} + /// Signal operations for one scope (one linked device). /// -/// Every operation that changes Signal state holds one lock per device: -/// staged views commit whole batches, so two concurrent views writing the -/// same session would lose a ratchet step. The store's lease fencing protects -/// against a second node. +/// Every operation that changes Signal state first locks exactly the state it +/// can write: a peer's session, a sender-key chain, or a group's distribution +/// marks. Staged views commit whole batches, so two views writing the same +/// record would lose an update; disjoint views proceed in parallel. Locks are +/// taken in sorted order, so overlapping operations cannot deadlock. The +/// store's lease fencing protects against a second node. pub struct SignalOps { store: Arc, keys: RwLock>, /// Held while device keys are read-modify-written. device_write: Mutex<()>, - session_lock: Arc>, + locks: Arc, sender_key_locks: Arc, } @@ -181,16 +208,17 @@ impl SignalOps { } /// Generate and persist device keys for a new scope. Refuses to replace - /// existing keys: that would silently unlink the device. + /// existing keys, which would silently unlink the device; the write is an + /// exclusive insert, so concurrent initialisers cannot both succeed. pub async fn create(store: Arc) -> Result { - if store.load_device_keys().await?.is_some() { - return Err(OpsError::InvalidInput( + let keys = DeviceKeys::generate()?; + match store.create_device_keys(&keys.encode()).await { + Ok(()) => Ok(Self::with_keys(store, keys)), + Err(e) if wacore_recordstore::is_record_exists(&e) => Err(OpsError::InvalidInput( "scope already has device keys".into(), - )); + )), + Err(e) => Err(e.into()), } - let keys = DeviceKeys::generate()?; - store.save_device_keys(&keys.encode()).await?; - Ok(Self::with_keys(store, keys)) } fn with_keys(store: Arc, keys: DeviceKeys) -> Self { @@ -198,7 +226,7 @@ impl SignalOps { store, keys: RwLock::new(Arc::new(keys)), device_write: Mutex::new(()), - session_lock: Arc::new(Mutex::new(())), + locks: Arc::new(KeyedLocks::default()), sender_key_locks: Arc::new(KeyedLocks::default()), } } @@ -219,8 +247,16 @@ impl SignalOps { &self.store } - pub(crate) fn session_lock(&self) -> Arc> { - self.session_lock.clone() + /// Lock every key, in sorted order. Hold the result for the duration of + /// the operation. + pub(crate) async fn lock_keys(&self, mut keys: Vec) -> Held { + keys.sort(); + keys.dedup(); + let mut held = Vec::with_capacity(keys.len()); + for key in keys { + held.push(self.locks.get(&key).lock_arc().await); + } + held } pub(crate) async fn keys(&self) -> Arc { @@ -338,8 +374,7 @@ impl SignalOps { address: &ProtocolAddress, bundle: &RemoteBundle, ) -> Result<(), OpsError> { - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![session_key(address)]).await; let staged = Arc::new(self.store.staged()); self.establish_into(&staged, address, bundle).await?; staged.commit().await?; @@ -377,8 +412,7 @@ impl SignalOps { kind: EncKind, ciphertext: &[u8], ) -> Result { - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![session_key(sender)]).await; let staged = Arc::new(self.store.staged()); let decrypted = self.decrypt_into(&staged, sender, kind, ciphertext).await?; staged.commit().await?; @@ -439,8 +473,7 @@ impl SignalOps { recipient: &ProtocolAddress, plaintext: &[u8], ) -> Result { - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![session_key(recipient)]).await; let mut sessions = self.stores().await; let mut identities = sessions.clone(); let message = message_encrypt(plaintext, recipient, &mut sessions, &mut identities).await?; @@ -467,8 +500,9 @@ impl SignalOps { own_address: &ProtocolAddress, ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, own_address.as_str()); - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self + .lock_keys(vec![chain_key(group, own_address.as_str())]) + .await; let mut store = self.stores().await; let skdm = create_sender_key_distribution_message(&name, &mut store, &mut rng()).await?; Ok(skdm.serialized().to_vec()) @@ -482,8 +516,9 @@ impl SignalOps { plaintext: &[u8], ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, own_address.as_str()); - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self + .lock_keys(vec![chain_key(group, own_address.as_str())]) + .await; let mut store = self.stores().await; let message = group_encrypt(&mut store, &name, plaintext, &mut rng()).await?; Ok(message.serialized().to_vec()) @@ -498,6 +533,9 @@ impl SignalOps { ) -> Result<(), OpsError> { let name = SenderKeyName::from_parts(group, sender.as_str()); let message = SenderKeyDistributionMessage::try_from(skdm)?; + let _held = self + .lock_keys(vec![chain_key(group, sender.as_str())]) + .await; let mut store = self.stores().await; process_sender_key_distribution_message(&name, &message, &mut store).await?; Ok(()) @@ -539,8 +577,9 @@ impl SignalOps { ciphertext: &[u8], ) -> Result, OpsError> { let name = SenderKeyName::from_parts(group, sender.as_str()); - let lock = self.session_lock.clone(); - let _guard = lock.lock().await; + let _held = self + .lock_keys(vec![chain_key(group, sender.as_str())]) + .await; let mut store = self.stores().await; Ok(group_decrypt(ciphertext, &mut store, &name).await?) } diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs index 8005d7bd..d7ef643d 100644 --- a/wacore/signal-ops/src/outbound.rs +++ b/wacore/signal-ops/src/outbound.rs @@ -10,16 +10,16 @@ use crate::error::OpsError; use crate::ops::RemoteBundle; -use crate::ops::{ServiceStore, SignalOps}; +use crate::ops::{ServiceStore, SignalOps, chain_key, group_key, session_key}; use std::collections::HashSet; use wacore::client::context::SendContextResolver; use wacore::libsignal::protocol::{UsePQRatchet, process_prekey_bundle}; use wacore::libsignal::store::sender_key_name::SenderKeyName; use wacore::runtime::Runtime; use wacore::send::{ - DmStanzaRequest, GroupStanzaRequest, ResolvedDmDevices, ResolvedGroupDevices, - SenderKeyDistributionPolicy, SignalStores, prepare_dm_stanza, prepare_group_stanza, - retain_skdm_distribution_targets, + DmSignalAddressing, DmStanzaRequest, GroupStanzaRequest, ResolvedDmDevices, + ResolvedGroupDevices, SenderKeyDistributionPolicy, SignalStores, prepare_dm_stanza, + prepare_group_stanza, retain_skdm_distribution_targets, }; use wacore::send::{PairwiseRetryDestination, PairwiseRetryRequest, prepare_pairwise_retry_stanza}; use wacore::types::jid::JidExt as _; @@ -44,9 +44,12 @@ fn sent_key(chat: &Jid, message_id: &str) -> String { format!("{chat}\u{0}{message_id}") } -/// Sent-message record: `[has_secret][secret (32 bytes, when set)][message]`. +/// Sent-message record: `[inserted ms, u64 BE][has_secret][secret (32 bytes, +/// when set)][message]`. fn encode_sent(message: &[u8], secret: Option<&[u8; 32]>) -> Vec { - let mut out = Vec::with_capacity(1 + 32 + message.len()); + let inserted_ms = u64::try_from(wacore::time::now_utc().timestamp_millis()).unwrap_or(0); + let mut out = Vec::with_capacity(8 + 1 + 32 + message.len()); + out.extend_from_slice(&inserted_ms.to_be_bytes()); match secret { Some(secret) => { out.push(1); @@ -58,6 +61,19 @@ fn encode_sent(message: &[u8], secret: Option<&[u8; 32]>) -> Vec { out } +/// `(inserted ms, message bytes)` of a sent-message record. +fn decode_sent(record: &[u8]) -> Result<(u64, &[u8]), OpsError> { + let corrupt = || OpsError::InvalidInput("sent-message record is corrupt".into()); + let (inserted, rest) = record.split_at_checked(8).ok_or_else(corrupt)?; + let inserted_ms = u64::from_be_bytes(inserted.try_into().map_err(|_| corrupt())?); + let body = match rest.first() { + Some(0) => &rest[1..], + Some(1) if rest.len() >= 33 => &rest[33..], + _ => return Err(corrupt()), + }; + Ok((inserted_ms, body)) +} + fn send_error(error: anyhow::Error) -> OpsError { OpsError::Send(format!("{error:#}")) } @@ -111,6 +127,18 @@ struct OwnIdentity { account: Option, } +/// The address a device is encrypted to: its LID when the client knows the +/// mapping for a phone-number device, otherwise the device itself. +async fn encryption_jid(resolver: &dyn SendContextResolver, device: &Jid) -> Jid { + if device.is_pn() + && let Some(lid_user) = resolver.get_lid_for_phone(&device.user).await + && let Ok(lid) = format!("{lid_user}@lid").parse::() + { + return lid.with_device(device.device); + } + device.clone() +} + fn sender_key_device_key(group: &str, device: &Jid) -> String { format!("{group}\u{0}{device}") } @@ -167,8 +195,21 @@ impl SignalOps { wacore::types::jid::sort_dedup_by_device(&mut devices); let resolved = ResolvedDmDevices::new(devices, &own_jid, own_lid.as_ref()); - let lock = self.session_lock(); - let _guard = lock.lock().await; + // Fix each device's Signal address up front (its LID when the client + // knows one, as wacore's fan-out would) so the locks taken here cover + // exactly the sessions the encrypt will write. + let mut encryption = Vec::with_capacity(resolved.devices().len()); + for device in resolved.devices() { + encryption.push(encryption_jid(resolver, device).await); + } + let held_keys: Vec = encryption + .iter() + .map(|jid| session_key(&jid.to_protocol_address())) + .collect(); + let mut lock_jids = encryption.clone(); + lock_jids.sort_by_key(|jid| jid.to_protocol_address().as_str().to_owned()); + let _ = resolved.signal_addressing_or_init(DmSignalAddressing::new(encryption, lock_jids)); + let _held = self.lock_keys(held_keys).await; let staged = std::sync::Arc::new(self.store().staged()); let stores = self.stores_over(staged.clone()).await; let (mut sender_keys, mut sessions, mut identities, mut prekeys, signed_prekeys) = ( @@ -238,15 +279,7 @@ impl SignalOps { else { return Ok(None); }; - let body = match record.first() { - Some(0) => &record[1..], - Some(1) if record.len() >= 33 => &record[33..], - _ => { - return Err(OpsError::InvalidInput( - "sent-message record is corrupt".into(), - )); - } - }; + let (_, body) = decode_sent(&record)?; waproto::codec::message_decode(body) .map(Some) .map_err(|e| OpsError::InvalidInput(format!("sent message did not decode: {e}"))) @@ -314,8 +347,17 @@ impl SignalOps { let addressed = ResolvedGroupDevices::new(devices); let group_str = group.to_string(); - let lock = self.session_lock(); - let _guard = lock.lock().await; + let mut held_keys = vec![ + chain_key(&group_str, own_sending.to_protocol_address().as_str()), + group_key(&group_str), + ]; + for device in addressed.devices() { + held_keys.push(session_key(&device.to_protocol_address())); + held_keys.push(session_key( + &encryption_jid(resolver, device).await.to_protocol_address(), + )); + } + let _held = self.lock_keys(held_keys).await; let own_chain = SenderKeyName::from_parts(&group_str, own_sending.to_protocol_address().as_str()); // A missing chain means a fresh key: everyone needs it, as in the @@ -421,8 +463,7 @@ impl SignalOps { ) -> Result<(), OpsError> { let OwnIdentity { pn, lid, .. } = self.own_identity().await?; let group_str = group.to_string(); - let lock = self.session_lock(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![group_key(&group_str)]).await; for device in devices { if device.is_same_user_as(&pn) || lid.as_ref().is_some_and(|l| device.is_same_user_as(l)) @@ -444,8 +485,7 @@ impl SignalOps { /// distributes it again. Use after membership changes. pub async fn forget_sender_key_devices(&self, group: &Jid) -> Result<(), OpsError> { let prefix = format!("{group}\u{0}"); - let lock = self.session_lock(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![group_key(&group.to_string())]).await; let keys = self .store() .scan_aux(Namespace::SenderKeyDevices, &prefix) @@ -472,8 +512,7 @@ impl SignalOps { let OwnIdentity { account, .. } = self.own_identity().await?; let address = request.encryption_jid.to_protocol_address(); - let lock = self.session_lock(); - let _guard = lock.lock().await; + let _held = self.lock_keys(vec![session_key(&address)]).await; let staged = std::sync::Arc::new(self.store().staged()); if let Some(bundle) = request.bundle { self.establish_into(&staged, &address, bundle).await?; @@ -536,4 +575,22 @@ impl SignalOps { wacore_binary::marshal(&node) .map_err(|e| OpsError::Send(format!("stanza did not marshal: {e}"))) } + + /// Delete sent-message records inserted before `cutoff_ms`; a retry + /// receipt for an older message can no longer be answered. Returns how + /// many were removed. + pub async fn prune_sent_messages(&self, cutoff_ms: u64) -> Result { + let store = self.store(); + let mut expired = Vec::new(); + for key in store.scan_aux(Namespace::SentMessage, "").await? { + if let Some(record) = store.load_aux(Namespace::SentMessage, &key).await? + && decode_sent(&record)?.0 < cutoff_ms + { + expired.push(key); + } + } + let refs: Vec<&str> = expired.iter().map(String::as_str).collect(); + store.delete_aux(Namespace::SentMessage, &refs).await?; + Ok(expired.len()) + } } diff --git a/wacore/signal-ops/tests/receive.rs b/wacore/signal-ops/tests/receive.rs index 734e4ac4..3993bba9 100644 --- a/wacore/signal-ops/tests/receive.rs +++ b/wacore/signal-ops/tests/receive.rs @@ -259,6 +259,14 @@ fn only_own_devices_can_hand_over_app_state_keys() { .unwrap(), ); assert!(peer.app_state_key_share.is_none()); + assert!( + peer.content + .message + .protocol_message + .as_option() + .is_none_or(|p| p.app_state_sync_key_share.as_option().is_none()), + "a peer's key share must not reach the app inside the message" + ); let from_self = block_on(alice.ops.encrypt(&bob_addr, &padded(&share))).unwrap(); let own = message( @@ -294,3 +302,30 @@ fn pruning_drops_old_buffer_entries() { assert_eq!(block_on(bob.ops.prune_decrypt_buffer(0)).unwrap(), 0); assert_eq!(block_on(bob.ops.prune_decrypt_buffer(u64::MAX)).unwrap(), 1); } + +#[test] +fn a_bad_sender_key_does_not_block_the_message_carrying_it() { + let alice = device("alice-bad-skdm"); + let bob = device("bob-bad-skdm"); + let (alice_addr, bob_addr) = (address("111"), address("222")); + connect(&alice, &bob, &bob_addr); + let carrier = wa::Message { + conversation: Some("still readable".to_owned()), + sender_key_distribution_message: Some(wa::message::SenderKeyDistributionMessage { + group_id: Some(GROUP.to_owned()), + axolotl_sender_key_distribution_message: Some(vec![0xde, 0xad]), + }) + .into(), + ..Default::default() + }; + let sent = block_on(alice.ops.encrypt(&bob_addr, &padded(&carrier))).unwrap(); + let req = request(GROUP, &alice_addr, kind(&sent), &sent.ciphertext, false); + let first = message(block_on(bob.ops.receive(req)).unwrap()); + assert!(first.sender_key_rejected); + assert_eq!( + first.content.message.conversation.as_deref(), + Some("still readable") + ); + // Committed: a redelivery is served from the buffer, not decrypted again. + assert!(message(block_on(bob.ops.receive(req)).unwrap()).redelivered); +} diff --git a/wacore/signal-ops/tests/send.rs b/wacore/signal-ops/tests/send.rs index ec3242f7..28ea62ef 100644 --- a/wacore/signal-ops/tests/send.rs +++ b/wacore/signal-ops/tests/send.rs @@ -226,6 +226,10 @@ async fn a_stanza_built_by_the_service_decrypts_at_the_recipient() { let kept = alice.sent_message(&to, &id).await.unwrap().unwrap(); assert_eq!(kept.conversation.as_deref(), Some(body)); } + + assert_eq!(alice.prune_sent_messages(0).await.unwrap(), 0); + assert_eq!(alice.prune_sent_messages(u64::MAX).await.unwrap(), 2); + assert!(alice.sent_message(&to, "MSG0").await.unwrap().is_none()); } #[tokio::test(flavor = "multi_thread")] @@ -487,3 +491,97 @@ async fn a_retry_receipt_gets_the_message_re_encrypted() { .await; assert!(unknown.is_err()); } + +/// Holds every prekey fetch until released, like a slow peer lookup. +struct SlowClient { + inner: FakeClient, + gate: Arc, +} + +#[async_trait] +impl SendContextResolver for SlowClient { + async fn resolve_devices(&self, jids: &[Jid]) -> Result, anyhow::Error> { + self.inner.resolve_devices(jids).await + } + + async fn fetch_prekeys( + &self, + jids: &[Jid], + ) -> Result, anyhow::Error> { + self.gate.notified().await; + self.inner.fetch_prekeys(jids).await + } + + async fn fetch_prekeys_for_identity_check( + &self, + jids: &[Jid], + ) -> Result { + Ok(PreKeyFetchOutcome { + bundles: self.fetch_prekeys(jids).await?, + rejected: Vec::new(), + }) + } + + async fn resolve_group_routing_info( + &self, + jid: &Jid, + ) -> Result, anyhow::Error> { + self.inner.resolve_group_routing_info(jid).await + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_slow_send_does_not_block_receiving_from_another_peer() { + let alice_jid: Jid = "111:1@s.whatsapp.net".parse().unwrap(); + let carol_jid: Jid = "333:1@s.whatsapp.net".parse().unwrap(); + let dave_jid: Jid = "444:1@s.whatsapp.net".parse().unwrap(); + let alice = device("alice-slow", "111:1@s.whatsapp.net").await; + let carol = device("carol-slow", "333:1@s.whatsapp.net").await; + let dave = Arc::new(device("dave-slow", "444:1@s.whatsapp.net").await); + + let gate = Arc::new(tokio::sync::Notify::new()); + let slow = Arc::new(SlowClient { + inner: FakeClient { + devices: vec![carol_jid.clone()], + bundles: HashMap::from([(carol_jid.clone(), bundle_for(&carol).await)]), + group: None, + }, + gate: gate.clone(), + }); + let send = { + let dave = dave.clone(); + let slow = slow.clone(); + let to = carol_jid.to_non_ad(); + tokio::spawn(async move { + dave.send_direct(&TokioRuntime, slow.as_ref(), &to, &text("to carol"), "S1") + .await + }) + }; + + // Alice writes to Dave while Dave's send is stuck fetching Carol's keys. + let to_dave = FakeClient { + devices: vec![dave_jid.clone()], + bundles: HashMap::from([(dave_jid.clone(), bundle_for(&dave).await)]), + group: None, + }; + let sent = alice + .send_direct( + &TokioRuntime, + &to_dave, + &dave_jid.to_non_ad(), + &text("to dave"), + "A1", + ) + .await + .unwrap(); + let received = tokio::time::timeout( + Duration::from_secs(5), + deliver(&dave, &alice_jid, sent.stanza), + ) + .await + .expect("receive was blocked by an unrelated send"); + assert_eq!(received.as_deref(), Some("to dave")); + + gate.notify_one(); + send.await.unwrap().unwrap(); +} diff --git a/wacore/signal-wasm/src/adapters.rs b/wacore/signal-wasm/src/adapters.rs index 7d6775ab..6a49946d 100644 --- a/wacore/signal-wasm/src/adapters.rs +++ b/wacore/signal-wasm/src/adapters.rs @@ -10,7 +10,7 @@ use js_sys::{Array, Object, Promise, Reflect, Uint8Array}; use send_wrapper::SendWrapper; use wacore::store::error::{Result, StoreError}; use wacore_recordstore::{ - Fence, FenceLost, Lease, LeaseStore, Namespace, RecordStore, SealError, WriteOp, + Fence, FenceLost, Lease, LeaseStore, Namespace, RecordExists, RecordStore, SealError, WriteOp, }; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -103,11 +103,10 @@ fn describe(error: &JsValue) -> String { .unwrap_or_else(|| "JavaScript error".to_owned()) } -fn is_fence_lost(error: &JsValue) -> bool { +fn error_code(error: &JsValue) -> Option { Reflect::get(error, &JsValue::from_str("code")) .ok() .and_then(|c| c.as_string()) - .is_some_and(|c| c == "fence_lost") } fn store_error(error: JsValue) -> StoreError { @@ -134,6 +133,7 @@ fn op_to_js(op: &WriteOp) -> JsValue { WriteOp::Put { ns, key, value } => ("put", ns, key, Some(value)), WriteOp::Update { ns, key, value } => ("update", ns, key, Some(value)), WriteOp::Delete { ns, key } => ("delete", ns, key, None), + WriteOp::Insert { ns, key, value } => ("insert", ns, key, Some(value)), }; crate::convert::set(&out, "op", &JsValue::from_str(kind)); crate::convert::set(&out, "ns", &JsValue::from_str(ns.tag())); @@ -205,16 +205,25 @@ impl RecordStore for JsRecords { settle(self.0.write(fence_to_js(fence), list)) .await .map(|_| ()) - .map_err(|error| { - if is_fence_lost(&error) { - FenceLost { - scope: fence.scope.clone(), - generation: fence.generation, - } - .into_store_error() - } else { - store_error(error) + .map_err(|error| match error_code(&error).as_deref() { + Some("fence_lost") => FenceLost { + scope: fence.scope.clone(), + generation: fence.generation, } + .into_store_error(), + Some("record_exists") => { + // The backend does not say which insert collided; report the + // first one in the batch. + let (ns, key) = ops + .iter() + .find_map(|op| match op { + WriteOp::Insert { ns, key, .. } => Some((*ns, key.clone())), + _ => None, + }) + .unwrap_or((Namespace::Device, String::new())); + RecordExists { ns, key }.into_store_error() + } + _ => store_error(error), }) } } diff --git a/wacore/signal-wasm/src/convert.rs b/wacore/signal-wasm/src/convert.rs index b593340d..2b4a985e 100644 --- a/wacore/signal-wasm/src/convert.rs +++ b/wacore/signal-wasm/src/convert.rs @@ -2,7 +2,7 @@ use js_sys::{Array, Error, Object, Reflect, Uint8Array}; use std::sync::Arc; -use wacore::libsignal::protocol::{DeviceId, ProtocolAddress}; +use wacore::libsignal::protocol::ProtocolAddress; use wacore_recordstore::{Fence, Lease}; use wacore_signal_ops::{OpsError, PublicIdentity, PublicPreKey, PublicSignedPreKey, RemoteBundle}; use wasm_bindgen::{JsCast, JsValue}; @@ -56,8 +56,16 @@ fn error_chain(error: &dyn std::error::Error) -> String { message } -pub fn address(user: &str, device: u32) -> ProtocolAddress { - ProtocolAddress::new(user, DeviceId::from(device)) +/// The Signal address of `device` of `user`, encoded the way every wacore +/// store keys it (`JidExt::to_protocol_address`), so sessions created here +/// are the same records the send and receive paths use. +pub fn address(user: &str, device: u32) -> Result { + use wacore::types::jid::JidExt as _; + let device = u16::try_from(device).map_err(|_| invalid("device must fit in 16 bits"))?; + let jid: wacore_binary::Jid = user + .parse() + .map_err(|_| invalid(&format!("{user} is not a JID")))?; + Ok(jid.with_device(device).to_protocol_address()) } fn bytes(value: &[u8]) -> JsValue { diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index 2e970701..a5391a8f 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -1,4 +1,4 @@ -//! Browser and edge bindings for [`wacore_signal_ops`]. +//! Browser and edge bindings for `wacore_signal_ops`. //! //! The page supplies storage and sealing: a `RecordBackend` (IndexedDB in a //! browser, anything in an edge worker) and a `Sealer` (WebCrypto, so the data @@ -28,14 +28,15 @@ const TS_TYPES: &str = r#" export interface Fence { scope: string; generation: number } export interface Lease { scope: string; generation: number; holder: string; expiresAtMs: number } export type WriteOp = - | { op: "put" | "update"; ns: string; key: string; value: Uint8Array } + | { op: "put" | "update" | "insert"; ns: string; key: string; value: Uint8Array } | { op: "delete"; ns: string; key: string }; /** * Byte storage for one or more scopes. `write` applies every op or none, and * only while `fence.generation` is the scope's current lease generation; * otherwise it rejects with an error whose `code` is "fence_lost". An - * "update" op on a missing key is a no-op, never an insert. + * "update" op on a missing key is a no-op, never an insert. An "insert" op on + * an existing key rejects the whole batch with `code` "record_exists". */ export interface RecordBackend { get(scope: string, ns: string, key: string): Promise; @@ -111,12 +112,21 @@ impl SignalDevice { Arc::new(JsSealerAdapter::new(sealer)), lease.fence.clone(), )); - let ops = match SignalOps::open(store.clone()).await { + let opened = match SignalOps::open(store.clone()).await { + Ok(ops) => Ok(ops), + Err(OpsError::NotInitialised) if create => SignalOps::create(store).await, + Err(e) => Err(e), + }; + let ops = match opened { Ok(ops) => ops, - Err(OpsError::NotInitialised) if create => { - SignalOps::create(store).await.map_err(ops_error)? + Err(e) => { + // No SignalDevice is returned, so nobody else could release + // this lease; give it up now rather than block other holders + // until it expires. A failed release changes nothing for the + // caller, who gets the original error. + let _ = records.release(&lease).await; + return Err(ops_error(e)); } - Err(e) => return Err(ops_error(e)), }; Ok(SignalDevice { inner: Rc::new(Inner { @@ -213,7 +223,7 @@ impl SignalDevice { promise(async move { let found = inner .ops - .has_session(&address(&user, device)) + .has_session(&address(&user, device)?) .await .map_err(ops_error)?; Ok(JsValue::from_bool(found)) @@ -228,7 +238,7 @@ impl SignalDevice { let bundle = remote_bundle(&bundle)?; inner .ops - .establish_session(&address(&user, device), &bundle) + .establish_session(&address(&user, device)?, &bundle) .await .map_err(ops_error)?; Ok(JsValue::UNDEFINED) @@ -251,7 +261,7 @@ impl SignalDevice { }; let result = inner .ops - .decrypt(&address(&user, device), kind, &ciphertext) + .decrypt(&address(&user, device)?, kind, &ciphertext) .await .map_err(ops_error)?; let out = Object::new(); @@ -275,7 +285,7 @@ impl SignalDevice { promise(async move { let result = inner .ops - .encrypt(&address(&user, device), &plaintext) + .encrypt(&address(&user, device)?, &plaintext) .await .map_err(ops_error)?; let out = Object::new(); @@ -295,7 +305,7 @@ impl SignalDevice { promise(async move { let skdm = inner .ops - .sender_key_distribution(&group, &address(&user, device)) + .sender_key_distribution(&group, &address(&user, device)?) .await .map_err(ops_error)?; Ok(Uint8Array::from(skdm.as_slice()).into()) @@ -314,7 +324,7 @@ impl SignalDevice { promise(async move { let ciphertext = inner .ops - .group_encrypt(&group, &address(&user, device), &plaintext) + .group_encrypt(&group, &address(&user, device)?, &plaintext) .await .map_err(ops_error)?; Ok(Uint8Array::from(ciphertext.as_slice()).into()) @@ -333,7 +343,7 @@ impl SignalDevice { promise(async move { inner .ops - .process_sender_key_distribution(&group, &address(&user, device), &skdm) + .process_sender_key_distribution(&group, &address(&user, device)?, &skdm) .await .map_err(ops_error)?; Ok(JsValue::UNDEFINED) @@ -352,7 +362,7 @@ impl SignalDevice { promise(async move { let plaintext = inner .ops - .group_decrypt(&group, &address(&user, device), &ciphertext) + .group_decrypt(&group, &address(&user, device)?, &ciphertext) .await .map_err(ops_error)?; Ok(Uint8Array::from(plaintext.as_slice()).into()) @@ -388,7 +398,7 @@ impl SignalDevice { )); } }; - let sender = address(&user, device); + let sender = address(&user, device)?; let received = inner .ops .receive(ReceiveRequest { diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index 9299da6e..c215b359 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -50,9 +50,14 @@ class MemoryBackend { if (this.leases.get(fence.scope)?.generation !== fence.generation) { throw Object.assign(new Error("fence lost"), { code: "fence_lost" }); } + for (const op of ops) { + if (op.op === "insert" && this.records.has(this.#slot(fence.scope, op.ns, op.key))) { + throw Object.assign(new Error("record exists"), { code: "record_exists" }); + } + } for (const op of ops) { const slot = this.#slot(fence.scope, op.ns, op.key); - if (op.op === "put") this.records.set(slot, op.value.slice()); + if (op.op === "put" || op.op === "insert") this.records.set(slot, op.value.slice()); else if (op.op === "update") { if (this.records.has(slot)) this.records.set(slot, op.value.slice()); } else this.records.delete(slot); @@ -213,13 +218,16 @@ test("group messages decrypt with a distributed sender key", async () => { assert.equal(read(await bob.signal.groupDecrypt(group, ALICE, 1, skmsg)), "hello group"); }); -test("a sealer with the wrong key cannot open the device", async () => { +test("a sealer with the wrong key cannot open the device, and the lease is released", async () => { const backend = new MemoryBackend(); - await device("bob-key", { backend }); + const first = await device("bob-key", { backend }); + await first.signal.release(); await assert.rejects( - SignalDevice.open(backend, await webCryptoSealer(), "bob-key", "tab-a", 1, TTL, false), + SignalDevice.open(backend, await webCryptoSealer(), "bob-key", "tab-b", 1, TTL, false), (error) => error.code === "store", ); + // The failed open gave its lease back, so another tab can take the scope. + await SignalDevice.open(backend, first.sealer, "bob-key", "tab-c", 2, TTL, false); }); test("pairing and input errors carry codes", async () => { From 7614b60f4244c4118fcc99ee86ca30f5cd336daa Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 05:07:51 +0300 Subject: [PATCH 11/17] fix(signal-ops): redistribute the sender key after a group retry A group retry receipt means the requester may have lost this device's sender key. resend now forgets the requester's distribution mark for the group, so the next group send gives it the key again, matching WA Web and whatsapp-rust's mark_requester_for_fresh_skdm. The group test covers a retry followed by redistribution. --- wacore/signal-ops/src/outbound.rs | 20 +++++++++++++++++++- wacore/signal-ops/tests/send.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs index d7ef643d..8f734240 100644 --- a/wacore/signal-ops/src/outbound.rs +++ b/wacore/signal-ops/src/outbound.rs @@ -512,7 +512,25 @@ impl SignalOps { let OwnIdentity { account, .. } = self.own_identity().await?; let address = request.encryption_jid.to_protocol_address(); - let _held = self.lock_keys(vec![session_key(&address)]).await; + // A group retry also means the requester may have lost this device's + // sender key: forget its mark so the next group send redistributes it, + // as WA Web and the client do (mark_requester_for_fresh_skdm). + let mut held_keys = vec![session_key(&address)]; + if matches!(request.route, RetryRoute::Group { .. }) { + held_keys.push(group_key(&request.chat.to_string())); + } + let _held = self.lock_keys(held_keys).await; + if matches!(request.route, RetryRoute::Group { .. }) { + let group = request.chat.to_string(); + let keys: Vec = [request.requester, request.encryption_jid] + .into_iter() + .map(|jid| sender_key_device_key(&group, jid)) + .collect(); + let refs: Vec<&str> = keys.iter().map(String::as_str).collect(); + self.store() + .delete_aux(Namespace::SenderKeyDevices, &refs) + .await?; + } let staged = std::sync::Arc::new(self.store().staged()); if let Some(bundle) = request.bundle { self.establish_into(&staged, &address, bundle).await?; diff --git a/wacore/signal-ops/tests/send.rs b/wacore/signal-ops/tests/send.rs index 28ea62ef..0ddb1958 100644 --- a/wacore/signal-ops/tests/send.rs +++ b/wacore/signal-ops/tests/send.rs @@ -345,6 +345,35 @@ async fn a_group_send_distributes_the_sender_key_once_then_reuses_it() { Some("second") ); + // A retry receipt from Bob in the group makes the next send redistribute. + common::pair(&alice).await; + alice + .resend( + &client, + wacore_signal_ops::RetryRequest { + chat: &group, + message_id: "G2", + requester: &bob_jid, + encryption_jid: &bob_jid, + route: wacore_signal_ops::RetryRoute::Group { + addressing_mode: AddressingMode::Pn, + }, + retry_count: 1, + bundle: None, + }, + ) + .await + .unwrap(); + let after_retry = alice + .send_group(&TokioRuntime, &client, &group, &text("after retry"), "G2b") + .await + .unwrap(); + assert_eq!(after_retry.distribution_targets, vec![bob_jid.clone()]); + alice + .mark_sender_key_distributed(&group, &after_retry.distribution_targets) + .await + .unwrap(); + alice.forget_sender_key_devices(&group).await.unwrap(); let third = alice .send_group(&TokioRuntime, &client, &group, &text("third"), "G3") From 33a440e3e205aec375af6d49c3cfa8154613bd80 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 05:32:45 +0300 Subject: [PATCH 12/17] Forget a user's sessions and identities on identity change SignalOps::forget_user drops the stored identity and session of every device of one user (matching user and server exactly), under the session locks, in one fenced batch. Exposed to JS as forgetUser. This is the store side of HyperMeow's RemoteSignal.ForgetIdentity. --- wacore/recordstore/src/signal.rs | 17 ++++++++ wacore/signal-ops/src/ops.rs | 56 ++++++++++++++++++++++++++ wacore/signal-ops/tests/end_to_end.rs | 40 ++++++++++++++++++ wacore/signal-wasm/src/lib.rs | 12 ++++++ wacore/signal-wasm/tests/node.test.mjs | 12 ++++++ 5 files changed, 137 insertions(+) diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 69d86c66..742bfef6 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -335,6 +335,23 @@ impl RecordSignalStore { self.scan(ns, prefix, limit).await } + /// Peer addresses with a stored session or identity, ascending. Only the + /// keys are read, so callers can find every device of a user. + pub async fn scan_peer_addresses( + &self, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> Result> { + if !matches!(ns, Namespace::Session | Namespace::Identity) { + return Err(StoreError::Validation(format!( + "{} records do not hold peer addresses", + ns.tag() + ))); + } + self.scan(ns, prefix, limit).await + } + /// The local device's sealed key blob, if the scope has been initialised. pub async fn load_device(&self) -> Result>> { self.load(Namespace::Device, DEVICE_KEY).await diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 0e60ef82..5fe0a56e 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -40,6 +40,9 @@ pub trait ServiceStore: SignalStore + Sized + 'static { async fn put_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()>; async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> StoreResult<()>; async fn scan_aux(&self, ns: Namespace, prefix: &str) -> StoreResult>; + /// Addresses with a stored session ([`Namespace::Session`]) or identity + /// ([`Namespace::Identity`]) starting with `prefix`. + async fn scan_peer_addresses(&self, ns: Namespace, prefix: &str) -> StoreResult>; } #[cfg_attr(target_arch = "wasm32", async_trait(?Send))] @@ -80,6 +83,10 @@ impl ServiceStore for RecordSigna async fn scan_aux(&self, ns: Namespace, prefix: &str) -> StoreResult> { RecordSignalStore::scan_aux(self, ns, prefix, None).await } + + async fn scan_peer_addresses(&self, ns: Namespace, prefix: &str) -> StoreResult> { + RecordSignalStore::scan_peer_addresses(self, ns, prefix, None).await + } } /// Pairwise ciphertext kinds, named as in the `` attribute. @@ -363,6 +370,55 @@ impl SignalOps { Ok(public) } + /// Drop the stored identity and sessions of every device of `user`, after + /// the server reports that the user's identity changed. The next message + /// either way starts a fresh session. Sender keys are left alone: they are + /// replaced when the peer distributes a new one. + pub async fn forget_user(&self, user: &wacore_binary::Jid) -> Result<(), OpsError> { + use wacore::types::jid::JidExt; + let mut base = user.clone(); + base.device = 0; + // `{user}@{server}` for device 0, `{user}:{device}@{server}` otherwise. + let primary = base.to_signal_address_string(); + let server = &primary[user.user.len()..]; + let belongs = |address: &str| { + let Some(rest) = address.strip_prefix(user.user.as_str()) else { + return false; + }; + let rest = match rest.strip_prefix(':') { + Some(device) => match device.find('@') { + Some(at) if device[..at].bytes().all(|b| b.is_ascii_digit()) => &device[at..], + _ => return false, + }, + None => rest, + }; + rest.strip_prefix(server) + .is_some_and(|tail| tail.starts_with('.')) + }; + let mut addresses = Vec::new(); + for ns in [Namespace::Session, Namespace::Identity] { + for address in self.store.scan_peer_addresses(ns, &user.user).await? { + if belongs(&address) { + addresses.push(address); + } + } + } + addresses.sort(); + addresses.dedup(); + if addresses.is_empty() { + return Ok(()); + } + let _held = self + .lock_keys(addresses.iter().map(|a| format!("s:{a}")).collect()) + .await; + let batch: Vec> = addresses.iter().map(|a| Arc::from(a.as_str())).collect(); + let staged = self.store.staged(); + staged.delete_sessions_batch(&batch).await?; + staged.delete_identities_batch(&batch).await?; + staged.commit().await?; + Ok(()) + } + pub async fn has_session(&self, address: &ProtocolAddress) -> Result { Ok(self.store.has_session(address.as_str()).await?) } diff --git a/wacore/signal-ops/tests/end_to_end.rs b/wacore/signal-ops/tests/end_to_end.rs index c7e101ef..330f67f7 100644 --- a/wacore/signal-ops/tests/end_to_end.rs +++ b/wacore/signal-ops/tests/end_to_end.rs @@ -268,3 +268,43 @@ fn pairing_refuses_a_forged_container() { let refused = block_on(bob.ops.sign_pairing(b"not a pairing container")); assert!(matches!(refused, Err(OpsError::Pairing { .. }))); } + +#[test] +fn forget_user_drops_every_device_of_that_user_only() { + let records = Arc::new(MemoryRecordStore::new()); + let store = store_for(&records, "forget", "node-a", 0); + let ops = block_on(SignalOps::create(store.clone())).unwrap(); + let forgotten = ["123@c.us.0", "123:7@c.us.0", "123:12@c.us.0"]; + let kept = ["1234@c.us.0", "1234:7@c.us.0", "123@lid.0", "123:7@lid.0"]; + // Stored addresses use WA Web's `c.us` server for phone-number users. + for address in forgotten.iter().chain(&kept) { + block_on(store.put_session(address, b"session")).unwrap(); + block_on(store.put_identity(address, [9; 32])).unwrap(); + } + + let user: wacore_binary::Jid = "123:7@s.whatsapp.net".parse().unwrap(); + block_on(ops.forget_user(&user)).unwrap(); + + for address in forgotten { + assert!( + block_on(store.get_session(address)).unwrap().is_none(), + "{address}" + ); + assert!( + block_on(store.load_identity(address)).unwrap().is_none(), + "{address}" + ); + } + for address in kept { + assert!( + block_on(store.get_session(address)).unwrap().is_some(), + "{address}" + ); + assert!( + block_on(store.load_identity(address)).unwrap().is_some(), + "{address}" + ); + } + // Nothing left to forget is not an error. + block_on(ops.forget_user(&user)).unwrap(); +} diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index a5391a8f..bd7b172d 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -599,6 +599,18 @@ impl SignalDevice { }) } + /// Drop the identity and sessions of every device of a user whose + /// identity changed, as reported by the server. + #[wasm_bindgen(js_name = forgetUser)] + pub fn forget_user(&self, user: String) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let user = convert::jid(&user)?; + inner.ops.forget_user(&user).await.map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + /// Verify the primary's pair-success container and add the device /// signature. Rejects with code "pairing_refused" and a `status`. #[wasm_bindgen(js_name = signPairing)] diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index c215b359..11229564 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -335,3 +335,15 @@ test("sends are built in the page and only ask the resolver for devices and keys (error) => error.code === "invalid_input", ); }); + +test("an identity change forgets every session with that user", async () => { + const alice = await device("alice-forget"); + const bob = await device("bob-forget"); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + assert.equal(await alice.signal.hasSession(BOB, 1), true); + + await alice.signal.forgetUser(BOB); + assert.equal(await alice.signal.hasSession(BOB, 1), false); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + assert.equal(await alice.signal.hasSession(BOB, 1), true); +}); From b69bc85b5242d48047b87c2189d30693c247b229 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 10:02:29 +0300 Subject: [PATCH 13/17] Migrate PN sessions to LID and forget hosted devices - SignalOps::migrate_pn_to_lid moves a user's sessions and identities from phone-number to LID addresses, for regular and hosted devices, in one fenced batch under the session locks. PN session wins, LID identity wins, sender keys stay (as whatsapp-rust and WA Web do). Exposed to JS as migratePnToLid. - forget_user also covers the user's hosted (or hosted LID) devices, matching HyperMeow's local identity-change cleanup. - Address matching requires a numeric device suffix, so 'hosted' no longer matches 'hosted.lid' addresses. --- wacore/signal-ops/src/ops.rs | 149 ++++++++++++++++++++----- wacore/signal-ops/tests/end_to_end.rs | 66 ++++++++++- wacore/signal-wasm/src/lib.rs | 17 +++ wacore/signal-wasm/tests/node.test.mjs | 12 ++ 4 files changed, 212 insertions(+), 32 deletions(-) diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 5fe0a56e..877603b2 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -19,6 +19,8 @@ use wacore::libsignal::store::sender_key_name::SenderKeyName; use wacore::pair::PairUtils; use wacore::store::error::Result as StoreResult; use wacore::store::traits::SignalStore; +use wacore::types::jid::JidExt; +use wacore_binary::{Jid, Server}; use wacore_recordstore::{Namespace, RecordSignalStore, RecordStore, Sealer}; /// Storage the Signal service needs beyond [`SignalStore`]: the device key @@ -168,6 +170,40 @@ pub(crate) fn session_key(address: &ProtocolAddress) -> String { format!("s:{}", address.as_str()) } +/// Device ID of `address` if it is a stored Signal address of `user` on +/// `server`: `{user}@{server}.0` or `{user}:{device}@{server}.0`, with WA Web's +/// `c.us` spelling for phone numbers. +fn device_of(address: &str, user: &str, server: Server) -> Option { + let rest = address.strip_prefix(user)?; + let (device, rest) = match rest.strip_prefix(':') { + Some(tail) => { + let at = tail.find('@')?; + (tail[..at].parse().ok()?, &tail[at..]) + } + None => (0, rest), + }; + let signal_server = match server { + Server::Pn => "c.us", + other => other.as_str(), + }; + // The libsignal device suffix: `.0`. Requiring digits keeps `hosted` + // from matching `hosted.lid` addresses. + let suffix = rest + .strip_prefix('@')? + .strip_prefix(signal_server)? + .strip_prefix('.')?; + (!suffix.is_empty() && suffix.bytes().all(|b| b.is_ascii_digit())).then_some(device) +} + +/// `server` plus its hosted counterpart, if it has one. +fn with_hosted(server: Server) -> Vec { + match server { + Server::Pn => vec![Server::Pn, Server::Hosted], + Server::Lid => vec![Server::Lid, Server::HostedLid], + other => vec![other], + } +} + /// Lock key for one sender-key chain. pub(crate) fn chain_key(group: &str, sender: &str) -> String { format!("k:{group}\u{0}{sender}") @@ -370,40 +406,38 @@ impl SignalOps { Ok(public) } - /// Drop the stored identity and sessions of every device of `user`, after - /// the server reports that the user's identity changed. The next message - /// either way starts a fresh session. Sender keys are left alone: they are - /// replaced when the peer distributes a new one. - pub async fn forget_user(&self, user: &wacore_binary::Jid) -> Result<(), OpsError> { - use wacore::types::jid::JidExt; - let mut base = user.clone(); - base.device = 0; - // `{user}@{server}` for device 0, `{user}:{device}@{server}` otherwise. - let primary = base.to_signal_address_string(); - let server = &primary[user.user.len()..]; - let belongs = |address: &str| { - let Some(rest) = address.strip_prefix(user.user.as_str()) else { - return false; - }; - let rest = match rest.strip_prefix(':') { - Some(device) => match device.find('@') { - Some(at) if device[..at].bytes().all(|b| b.is_ascii_digit()) => &device[at..], - _ => return false, - }, - None => rest, - }; - rest.strip_prefix(server) - .is_some_and(|tail| tail.starts_with('.')) - }; - let mut addresses = Vec::new(); + /// Stored session and identity addresses of every device of `user` on + /// `server`, with their device IDs. + async fn device_addresses( + &self, + user: &str, + server: Server, + ) -> Result, OpsError> { + let mut found = Vec::new(); for ns in [Namespace::Session, Namespace::Identity] { - for address in self.store.scan_peer_addresses(ns, &user.user).await? { - if belongs(&address) { - addresses.push(address); + for address in self.store.scan_peer_addresses(ns, user).await? { + if let Some(device) = device_of(&address, user, server) { + found.push((address, device)); } } } - addresses.sort(); + found.sort(); + found.dedup(); + Ok(found) + } + + /// Drop the stored identity and sessions of every device of `user`, after + /// the server reports that the user's identity changed. A phone-number or + /// LID user also covers its hosted devices, as the client does. The next + /// message either way starts a fresh session. Sender keys are left alone: + /// they are replaced when the peer distributes a new one. + pub async fn forget_user(&self, user: &Jid) -> Result<(), OpsError> { + let mut addresses = Vec::new(); + for server in with_hosted(user.server) { + for (address, _) in self.device_addresses(&user.user, server).await? { + addresses.push(address); + } + } addresses.dedup(); if addresses.is_empty() { return Ok(()); @@ -419,6 +453,61 @@ impl SignalOps { Ok(()) } + /// Move a user's sessions and identities from phone-number addresses to + /// LID addresses once the client learns the mapping, for regular and + /// hosted devices. On a conflict the phone-number session wins (it is the + /// one the peer has been using) and an existing LID identity wins. Sender + /// keys are not moved; WA Web keeps them where they are. Returns how many + /// addresses moved. + pub async fn migrate_pn_to_lid(&self, pn: &Jid, lid: &Jid) -> Result { + if !pn.is_pn() || !lid.is_lid() { + return Err(OpsError::InvalidInput( + "migrate_pn_to_lid needs a phone-number JID and a LID".into(), + )); + } + let mut moves = Vec::new(); + for (from, to) in [ + (Server::Pn, Server::Lid), + (Server::Hosted, Server::HostedLid), + ] { + for (address, device) in self.device_addresses(&pn.user, from).await? { + let target = Jid::new(lid.user.clone(), to).with_device(device); + moves.push((address, target.to_protocol_address().as_str().to_owned())); + } + } + if moves.is_empty() { + return Ok(0); + } + let _held = self + .lock_keys( + moves + .iter() + .flat_map(|(from, to)| [format!("s:{from}"), format!("s:{to}")]) + .collect(), + ) + .await; + let staged = self.store.staged(); + let mut moved = 0; + for (from, to) in &moves { + let mut changed = false; + if let Some(session) = staged.get_session(from).await? { + staged.put_session(to, &session).await?; + staged.delete_session(from).await?; + changed = true; + } + if let Some(identity) = staged.load_identity(from).await? { + if staged.load_identity(to).await?.is_none() { + staged.put_identity(to, identity).await?; + } + staged.delete_identity(from).await?; + changed = true; + } + moved += usize::from(changed); + } + staged.commit().await?; + Ok(moved) + } + pub async fn has_session(&self, address: &ProtocolAddress) -> Result { Ok(self.store.has_session(address.as_str()).await?) } diff --git a/wacore/signal-ops/tests/end_to_end.rs b/wacore/signal-ops/tests/end_to_end.rs index 330f67f7..1b20f77e 100644 --- a/wacore/signal-ops/tests/end_to_end.rs +++ b/wacore/signal-ops/tests/end_to_end.rs @@ -274,8 +274,20 @@ fn forget_user_drops_every_device_of_that_user_only() { let records = Arc::new(MemoryRecordStore::new()); let store = store_for(&records, "forget", "node-a", 0); let ops = block_on(SignalOps::create(store.clone())).unwrap(); - let forgotten = ["123@c.us.0", "123:7@c.us.0", "123:12@c.us.0"]; - let kept = ["1234@c.us.0", "1234:7@c.us.0", "123@lid.0", "123:7@lid.0"]; + let forgotten = [ + "123@c.us.0", + "123:7@c.us.0", + "123:12@c.us.0", + "123:99@hosted.0", + ]; + let kept = [ + "1234@c.us.0", + "1234:7@c.us.0", + "1234:99@hosted.0", + "123@lid.0", + "123:7@lid.0", + "123:99@hosted.lid.0", + ]; // Stored addresses use WA Web's `c.us` server for phone-number users. for address in forgotten.iter().chain(&kept) { block_on(store.put_session(address, b"session")).unwrap(); @@ -308,3 +320,53 @@ fn forget_user_drops_every_device_of_that_user_only() { // Nothing left to forget is not an error. block_on(ops.forget_user(&user)).unwrap(); } + +#[test] +fn migrate_pn_to_lid_moves_regular_and_hosted_devices() { + let records = Arc::new(MemoryRecordStore::new()); + let store = store_for(&records, "migrate", "node-a", 0); + let ops = block_on(SignalOps::create(store.clone())).unwrap(); + // Device 0 and the hosted device exist only under PN. + block_on(store.put_session("123@c.us.0", b"pn-0")).unwrap(); + block_on(store.put_identity("123@c.us.0", [1; 32])).unwrap(); + block_on(store.put_session("123:99@hosted.0", b"pn-hosted")).unwrap(); + block_on(store.put_identity("123:99@hosted.0", [2; 32])).unwrap(); + // Device 7 exists on both sides: PN session wins, LID identity wins. + block_on(store.put_session("123:7@c.us.0", b"pn-7")).unwrap(); + block_on(store.put_identity("123:7@c.us.0", [3; 32])).unwrap(); + block_on(store.put_session("456:7@lid.0", b"lid-7")).unwrap(); + block_on(store.put_identity("456:7@lid.0", [4; 32])).unwrap(); + // Another user sharing the prefix is untouched. + block_on(store.put_session("1234@c.us.0", b"other")).unwrap(); + + let pn: wacore_binary::Jid = "123@s.whatsapp.net".parse().unwrap(); + let lid: wacore_binary::Jid = "456@lid".parse().unwrap(); + assert_eq!(block_on(ops.migrate_pn_to_lid(&pn, &lid)).unwrap(), 3); + + let session = |a: &str| block_on(store.get_session(a)).unwrap().map(|b| b.to_vec()); + let identity = |a: &str| block_on(store.load_identity(a)).unwrap(); + assert_eq!(session("456@lid.0").as_deref(), Some(&b"pn-0"[..])); + assert_eq!(identity("456@lid.0"), Some([1; 32])); + assert_eq!( + session("456:99@hosted.lid.0").as_deref(), + Some(&b"pn-hosted"[..]) + ); + assert_eq!(identity("456:99@hosted.lid.0"), Some([2; 32])); + assert_eq!(session("456:7@lid.0").as_deref(), Some(&b"pn-7"[..])); + assert_eq!(identity("456:7@lid.0"), Some([4; 32])); + for gone in ["123@c.us.0", "123:7@c.us.0", "123:99@hosted.0"] { + assert!( + session(gone).is_none() && identity(gone).is_none(), + "{gone}" + ); + } + assert!(session("1234@c.us.0").is_some()); + + // Already migrated: nothing left to move. + assert_eq!(block_on(ops.migrate_pn_to_lid(&pn, &lid)).unwrap(), 0); + // Arguments the wrong way round are refused. + assert!(matches!( + block_on(ops.migrate_pn_to_lid(&lid, &pn)), + Err(OpsError::InvalidInput(_)) + )); +} diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index bd7b172d..bc1ac864 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -611,6 +611,23 @@ impl SignalDevice { }) } + /// Move a user's sessions and identities from phone-number to LID + /// addresses once the mapping is known. Resolves to how many moved. + #[wasm_bindgen(js_name = migratePnToLid)] + pub fn migrate_pn_to_lid(&self, pn: String, lid: String) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let pn = convert::jid(&pn)?; + let lid = convert::jid(&lid)?; + let moved = inner + .ops + .migrate_pn_to_lid(&pn, &lid) + .await + .map_err(ops_error)?; + Ok(JsValue::from_f64(moved as f64)) + }) + } + /// Verify the primary's pair-success container and add the device /// signature. Rejects with code "pairing_refused" and a `status`. #[wasm_bindgen(js_name = signPairing)] diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index 11229564..99e589fd 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -347,3 +347,15 @@ test("an identity change forgets every session with that user", async () => { await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); assert.equal(await alice.signal.hasSession(BOB, 1), true); }); + +test("sessions follow a user from phone number to LID", async () => { + const alice = await device("alice-migrate"); + const bob = await device("bob-migrate"); + const BOB_LID = "999@lid"; + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + + assert.equal(await alice.signal.migratePnToLid(BOB, BOB_LID), 1); + assert.equal(await alice.signal.hasSession(BOB, 1), false); + assert.equal(await alice.signal.hasSession(BOB_LID, 1), true); + assert.equal(await alice.signal.migratePnToLid(BOB, BOB_LID), 0); +}); From 673bd4c058375eaa41a36a5eadd268756bdc3424 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 15 Oct 2026 07:19:33 +0300 Subject: [PATCH 14/17] Persist fenced prekey upload batches and exact acknowledgments --- wacore/recordstore/src/signal.rs | 27 + wacore/signal-ops/src/lib.rs | 11 +- wacore/signal-ops/src/ops.rs | 57 +- wacore/signal-ops/src/prekeys.rs | 329 ++++++++++ wacore/signal-ops/tests/prekey_upload.rs | 726 +++++++++++++++++++++++ 5 files changed, 1111 insertions(+), 39 deletions(-) create mode 100644 wacore/signal-ops/src/prekeys.rs create mode 100644 wacore/signal-ops/tests/prekey_upload.rs diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index 742bfef6..c70f25b6 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -318,6 +318,33 @@ impl RecordSignalStore { self.put_one(ns, key, plaintext).await } + /// Insert an auxiliary record exclusively, including in a staged batch. + pub async fn insert_aux(&self, ns: Namespace, key: &str, plaintext: &[u8]) -> Result<()> { + Self::check_aux(ns)?; + self.apply(vec![PlainOp::Insert( + ns, + key.to_owned(), + plaintext.to_vec(), + )]) + .await + } + + /// Reserve fresh prekey IDs without overwriting an existing private key. + pub async fn insert_prekeys_batch(&self, keys: &[(u32, Bytes)]) -> Result<()> { + self.apply( + keys.iter() + .map(|(id, record)| { + PlainOp::Insert( + Namespace::PreKey, + id_key(*id), + prekey_plaintext(record, false), + ) + }) + .collect(), + ) + .await + } + /// Delete records in one of the service's own namespaces. pub async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> Result<()> { Self::check_aux(ns)?; diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index d20cf3c3..45a9905d 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -10,6 +10,14 @@ //! Message padding is the caller's concern: [`SignalOps::decrypt`] returns //! libsignal's plaintext exactly as received and [`SignalOps::encrypt`] takes //! already padded plaintext. +//! +//! Use [`SignalOps::prepare_prekey_upload`] for ordinary public-key uploads: +//! its sealed pending batch survives reopening, and retries do not allocate +//! more private keys. Acknowledge a successful server upload with +//! [`SignalOps::acknowledge_prekey_upload`]; the last exact acknowledgment is +//! replayable after a lost response. [`SignalOps::generate_prekeys`] remains +//! fresh generation for retry receipts. All three use the scope's fenced store; +//! upload batch IDs remain reserved across 24-bit wrap even after consumption. mod device; mod error; @@ -22,6 +30,7 @@ pub use device::{DeviceKeys, PublicIdentity, PublicPreKey, PublicSignedPreKey}; pub use error::OpsError; pub use inbound::{ReceiveKind, ReceiveRequest, Received, ReceivedMessage}; pub use ops::{ - Decrypted, EncKind, Encrypted, PairingSignature, RemoteBundle, ServiceStore, SignalOps, + Decrypted, EncKind, Encrypted, PairingSignature, PreKeyUploadBatch, RemoteBundle, ServiceStore, + SignalOps, }; pub use outbound::{PreparedGroupSend, PreparedSend, RetryRequest, RetryRoute}; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index 877603b2..dda87678 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -23,6 +23,10 @@ use wacore::types::jid::JidExt; use wacore_binary::{Jid, Server}; use wacore_recordstore::{Namespace, RecordSignalStore, RecordStore, Sealer}; +#[path = "prekeys.rs"] +mod prekeys; +pub use prekeys::PreKeyUploadBatch; + /// Storage the Signal service needs beyond [`SignalStore`]: the device key /// blob, its own records (decrypt buffer, sent messages), and staged views /// whose writes commit as one batch. @@ -40,6 +44,9 @@ pub trait ServiceStore: SignalStore + Sized + 'static { async fn commit(&self) -> StoreResult<()>; async fn load_aux(&self, ns: Namespace, key: &str) -> StoreResult>>; async fn put_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()>; + /// Exclusive inserts used to publish durable prekey uploads atomically. + async fn insert_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()>; + async fn insert_prekeys_batch(&self, keys: &[(u32, bytes::Bytes)]) -> StoreResult<()>; async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> StoreResult<()>; async fn scan_aux(&self, ns: Namespace, prefix: &str) -> StoreResult>; /// Addresses with a stored session ([`Namespace::Session`]) or identity @@ -78,6 +85,14 @@ impl ServiceStore for RecordSigna RecordSignalStore::put_aux(self, ns, key, value).await } + async fn insert_aux(&self, ns: Namespace, key: &str, value: &[u8]) -> StoreResult<()> { + RecordSignalStore::insert_aux(self, ns, key, value).await + } + + async fn insert_prekeys_batch(&self, keys: &[(u32, bytes::Bytes)]) -> StoreResult<()> { + RecordSignalStore::insert_prekeys_batch(self, keys).await + } + async fn delete_aux(&self, ns: Namespace, keys: &[&str]) -> StoreResult<()> { RecordSignalStore::delete_aux(self, ns, keys).await } @@ -330,45 +345,11 @@ impl SignalOps { self.keys.read().await.public() } - /// Create `count` one-time prekeys and return their public halves for - /// upload. The ID counter advances before the keys are written, so a crash - /// leaves a gap rather than reusing an ID. + /// Generate fresh one-time prekeys for retry receipts. Unlike + /// `prepare_prekey_upload`, each call reserves new IDs. The counter and + /// private keys commit together; active upload IDs remain reserved. pub async fn generate_prekeys(&self, count: u32) -> Result, OpsError> { - if count == 0 || count > 812 { - // 812 is WhatsApp Web's upload ceiling per request. - return Err(OpsError::InvalidInput( - "prekey count must be 1 to 812".into(), - )); - } - let _guard = self.device_write.lock().await; - let mut keys = (**self.keys.read().await).clone(); - let first = keys.next_prekey_id; - let mut ids = Vec::with_capacity(count as usize); - let mut next = first; - for _ in 0..count { - ids.push(next); - next = if next >= MAX_PREKEY_ID { 1 } else { next + 1 }; - } - keys.next_prekey_id = next; - self.replace_keys(keys).await?; - - let mut rng = rng(); - let mut records = Vec::with_capacity(ids.len()); - let mut public = Vec::with_capacity(ids.len()); - for id in ids { - let pair = KeyPair::generate(&mut rng); - let structure = record_helpers::new_pre_key_record(id, &pair); - records.push(( - id, - bytes::Bytes::from(waproto::codec::pre_key_record_to_vec(&structure)), - )); - public.push(PublicPreKey { - id, - public_key: public_bytes(&pair.public_key), - }); - } - self.store.store_prekeys_batch(&records, false).await?; - Ok(public) + self.generate_fresh_prekeys(count).await } /// Record that the client uploaded these prekeys. Consumed keys stay gone. diff --git a/wacore/signal-ops/src/prekeys.rs b/wacore/signal-ops/src/prekeys.rs new file mode 100644 index 00000000..acf8ef8d --- /dev/null +++ b/wacore/signal-ops/src/prekeys.rs @@ -0,0 +1,329 @@ +//! Durable public upload batches, separate from fresh retry prekeys. + +use super::*; +use rand::Rng; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use wacore::store::error::StoreError; + +const PENDING: &str = "prekey-upload-v1"; +const LAST_ACK: &str = "prekey-upload-ack-v1"; +const MAX_SCAN_IDS: u32 = 4096; +const SCAN_CHUNK: u32 = 128; +const HEADER: usize = 1 + 16 + 2; +const ENTRY: usize = 4 + 32; + +/// Only public key material. Retry the same batch until the upload is acknowledged. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PreKeyUploadBatch { + pub batch_id: String, + pub pre_keys: Vec, +} + +fn corrupt() -> OpsError { + StoreError::Validation("stored prekey upload batch is invalid".into()).into() +} + +fn count_valid(count: u32) -> Result<(), OpsError> { + if !(1..=812).contains(&count) { + return Err(OpsError::InvalidInput( + "prekey count must be 1 to 812".into(), + )); + } + Ok(()) +} + +impl PreKeyUploadBatch { + fn encode(&self) -> Result, OpsError> { + let id = hex::decode(&self.batch_id).map_err(|_| corrupt())?; + if id.len() != 16 || self.pre_keys.is_empty() || self.pre_keys.len() > 812 { + return Err(corrupt()); + } + let mut out = Vec::with_capacity(HEADER + ENTRY * self.pre_keys.len()); + out.push(1); + out.extend_from_slice(&id); + out.extend_from_slice(&(self.pre_keys.len() as u16).to_be_bytes()); + for key in &self.pre_keys { + out.extend_from_slice(&key.id.to_be_bytes()); + out.extend_from_slice(&key.public_key); + } + out.extend_from_slice(&Sha256::digest(&out)); + Ok(out) + } + + fn decode(bytes: &[u8]) -> Result { + if bytes.len() < HEADER || bytes[0] != 1 { + return Err(corrupt()); + } + let count = usize::from(u16::from_be_bytes([bytes[17], bytes[18]])); + let data_len = HEADER + count * ENTRY; + if count == 0 + || count > 812 + || bytes.len() != data_len + 32 + || Sha256::digest(&bytes[..data_len])[..] != bytes[data_len..] + { + return Err(corrupt()); + } + let mut seen = BTreeSet::new(); + let mut pre_keys = Vec::with_capacity(count); + for entry in bytes[HEADER..data_len].chunks_exact(ENTRY) { + let id = u32::from_be_bytes(entry[..4].try_into().map_err(|_| corrupt())?); + if !(1..=MAX_PREKEY_ID).contains(&id) || !seen.insert(id) { + return Err(corrupt()); + } + pre_keys.push(PublicPreKey { + id, + public_key: entry[4..].try_into().map_err(|_| corrupt())?, + }); + } + Ok(Self { + batch_id: hex::encode(&bytes[1..17]), + pre_keys, + }) + } +} + +impl SignalOps { + async fn pending_upload(&self) -> Result, OpsError> { + self.store + .load_aux(Namespace::Device, PENDING) + .await? + .map(|bytes| PreKeyUploadBatch::decode(&bytes)) + .transpose() + } + + async fn live_upload_keys( + &self, + batch: &PreKeyUploadBatch, + ) -> Result, OpsError> { + let ids: Vec<_> = batch.pre_keys.iter().map(|key| key.id).collect(); + let records: BTreeMap<_, _> = self + .store + .load_prekeys_batch(&ids) + .await? + .into_iter() + .collect(); + let mut live = Vec::new(); + for key in &batch.pre_keys { + if let Some(bytes) = records.get(&key.id) { + let structure = + waproto::codec::pre_key_record_decode(bytes).map_err(|_| corrupt())?; + let record = + record_helpers::prekey_structure_to_record(structure).map_err(|_| corrupt())?; + let pair = record.key_pair().map_err(|_| corrupt())?; + if u32::from(record.id().map_err(|_| corrupt())?) != key.id + || public_bytes(&pair.public_key) != key.public_key + || public_bytes(&pair.private_key.public_key().map_err(|_| corrupt())?) + != key.public_key + { + return Err(corrupt()); + } + live.push(key.clone()); + } + } + Ok(live) + } + + /// Recover the outstanding batch after restart, excluding consumed keys. + /// `count` applies only when no live batch remains. Keep one SignalOps + /// instance per held scope; its device lock serializes upload and retry work. + /// New batches accept 1..=812 keys and inspect at most 4096 candidate IDs; + /// allocation exhaustion at that bound leaves the counter and keys unchanged. + pub async fn prepare_prekey_upload(&self, count: u32) -> Result { + count_valid(count)?; + let _held = self.device_write.lock().await; + if let Some(batch) = self.pending_upload().await? { + let live = self.live_upload_keys(&batch).await?; + let staged = self.store.staged(); + if !live.is_empty() { + // A fenced write also refuses a superseded holder before any + // public batch is returned, including a recovered one. + staged + .put_aux(Namespace::Device, PENDING, &batch.encode()?) + .await?; + staged.commit().await?; + return Ok(PreKeyUploadBatch { + pre_keys: live, + ..batch + }); + } + staged.delete_aux(Namespace::Device, &[PENDING]).await?; + staged.commit().await?; + } + let mut batch_id = [0; 16]; + rng().fill_bytes(&mut batch_id); + let batch_id = hex::encode(batch_id); + let public = self + .allocate_prekeys(count, &BTreeSet::new(), Some(&batch_id)) + .await?; + Ok(PreKeyUploadBatch { + batch_id, + pre_keys: public, + }) + } + + /// Acknowledge exactly the uploaded IDs. They must be unique members of + /// this batch and cover every surviving private key. Consumed members may + /// be included so an in-flight upload can still be acknowledged safely. + /// The most recent exact acknowledgment remains replayable after restart. + pub async fn acknowledge_prekey_upload( + &self, + batch_id: &str, + exact_ids: &[u32], + ) -> Result<(), OpsError> { + let _held = self.device_write.lock().await; + let invalid = || { + OpsError::InvalidInput("prekey upload batch or acknowledgment does not match".into()) + }; + let ids: BTreeSet<_> = exact_ids.iter().copied().collect(); + if ids.is_empty() || ids.len() != exact_ids.len() { + return Err(invalid()); + } + if let Some(bytes) = self.store.load_aux(Namespace::Device, LAST_ACK).await? { + let acknowledged = PreKeyUploadBatch::decode(&bytes)?; + if acknowledged.batch_id == batch_id { + if acknowledged + .pre_keys + .iter() + .map(|key| key.id) + .collect::>() + != ids + { + return Err(invalid()); + } + let staged = self.store.staged(); + staged.put_aux(Namespace::Device, LAST_ACK, &bytes).await?; + staged.commit().await?; + return Ok(()); + } + } + let batch = self.pending_upload().await?.ok_or_else(invalid)?; + if batch.batch_id != batch_id { + return Err(invalid()); + } + let members: BTreeSet<_> = batch.pre_keys.iter().map(|key| key.id).collect(); + let live = self.live_upload_keys(&batch).await?; + if ids.len() != exact_ids.len() + || !ids.is_subset(&members) + || live.iter().any(|key| !ids.contains(&key.id)) + { + return Err(invalid()); + } + let staged = self.store.staged(); + staged.mark_prekeys_uploaded(exact_ids).await?; + let receipt = PreKeyUploadBatch { + batch_id: batch.batch_id, + pre_keys: batch + .pre_keys + .into_iter() + .filter(|key| ids.contains(&key.id)) + .collect(), + }; + staged + .put_aux(Namespace::Device, LAST_ACK, &receipt.encode()?) + .await?; + staged.delete_aux(Namespace::Device, &[PENDING]).await?; + staged.commit().await?; + Ok(()) + } + + pub(super) async fn generate_fresh_prekeys( + &self, + count: u32, + ) -> Result, OpsError> { + count_valid(count)?; + let _held = self.device_write.lock().await; + let reserved = if let Some(batch) = self.pending_upload().await? { + self.live_upload_keys(&batch).await?; + batch.pre_keys.into_iter().map(|key| key.id).collect() + } else { + BTreeSet::new() + }; + self.allocate_prekeys(count, &reserved, None).await + } + + async fn allocate_prekeys( + &self, + count: u32, + reserved: &BTreeSet, + batch_id: Option<&str>, + ) -> Result, OpsError> { + let mut keys = DeviceKeys::decode( + &self + .store + .load_device_keys() + .await? + .ok_or(OpsError::NotInitialised)?, + )?; + if !(1..=MAX_PREKEY_ID).contains(&keys.next_prekey_id) { + return Err(corrupt()); + } + let mut ids = Vec::with_capacity(count as usize); + let mut next = keys.next_prekey_id; + for _ in 0..MAX_SCAN_IDS / SCAN_CHUNK { + let mut candidates = Vec::with_capacity(SCAN_CHUNK as usize); + for _ in 0..SCAN_CHUNK { + let id = next; + next = if next == MAX_PREKEY_ID { 1 } else { next + 1 }; + if !reserved.contains(&id) { + candidates.push(id); + } + } + let occupied: BTreeSet<_> = self + .store + .load_prekeys_batch(&candidates) + .await? + .into_iter() + .map(|(id, _)| id) + .collect(); + for id in candidates { + if !occupied.contains(&id) { + ids.push(id); + if ids.len() == count as usize { + next = if id == MAX_PREKEY_ID { 1 } else { id + 1 }; + break; + } + } + } + if ids.len() == count as usize { + break; + } + } + if ids.len() != count as usize { + return Err(OpsError::InvalidInput( + "prekey allocation exceeded the 4096-ID scan limit; no keys were created".into(), + )); + } + keys.next_prekey_id = next; + let mut random = rng(); + let mut records = Vec::with_capacity(ids.len()); + let mut public = Vec::with_capacity(ids.len()); + for id in ids { + let pair = KeyPair::generate(&mut random); + let record = record_helpers::new_pre_key_record(id, &pair); + records.push(( + id, + bytes::Bytes::from(waproto::codec::pre_key_record_to_vec(&record)), + )); + public.push(PublicPreKey { + id, + public_key: public_bytes(&pair.public_key), + }); + } + let staged = self.store.staged(); + staged.insert_prekeys_batch(&records).await?; + staged.save_device_keys(&keys.encode()).await?; + if let Some(batch_id) = batch_id { + let batch = PreKeyUploadBatch { + batch_id: batch_id.to_owned(), + pre_keys: public.clone(), + }; + staged + .insert_aux(Namespace::Device, PENDING, &batch.encode()?) + .await?; + } + staged.commit().await?; + *self.keys.write().await = Arc::new(keys); + Ok(public) + } +} diff --git a/wacore/signal-ops/tests/prekey_upload.rs b/wacore/signal-ops/tests/prekey_upload.rs new file mode 100644 index 00000000..d385c7bc --- /dev/null +++ b/wacore/signal-ops/tests/prekey_upload.rs @@ -0,0 +1,726 @@ +//! Public upload retries recover from the sealed store, not process memory. +use async_trait::async_trait; +use futures::executor::block_on; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicU32, AtomicUsize, Ordering}, +}; +use wacore::store::{ + error::{Result as StoreResult, StoreError}, + traits::SignalStore, +}; +use wacore_recordstore::{ + AesGcmSealer, Fence, LeaseStore, MemoryRecordStore, Namespace, RecordSignalStore, RecordStore, + SealError, Sealer, WriteOp, +}; +use wacore_signal_ops::{DeviceKeys, SignalOps}; + +const SCOPE: &str = "synthetic-upload-device"; +const PENDING: &str = "prekey-upload-v1"; +const MAX: u32 = 0x00ff_ffff; + +#[derive(Default)] +struct Records { + inner: MemoryRecordStore, + fail: AtomicUsize, + consume: AtomicU32, + batches: Mutex>>, + reads: AtomicUsize, +} +#[async_trait] +impl RecordStore for Records { + async fn get(&self, scope: &str, ns: Namespace, key: &str) -> StoreResult>> { + self.reads.fetch_add(1, Ordering::SeqCst); + self.inner.get(scope, ns, key).await + } + async fn scan_keys( + &self, + scope: &str, + ns: Namespace, + prefix: &str, + limit: Option, + ) -> StoreResult> { + self.inner.scan_keys(scope, ns, prefix, limit).await + } + async fn write(&self, fence: &Fence, ops: &[WriteOp]) -> StoreResult<()> { + self.batches.lock().unwrap().push(ops.to_vec()); + let failure = self.fail.swap(0, Ordering::SeqCst); + let fails = ops.iter().any(|op| match (failure, op) { + ( + 1, + WriteOp::Put { + ns: Namespace::Device, + key, + .. + }, + ) => key == "local", + ( + 2, + WriteOp::Insert { + ns: Namespace::PreKey, + .. + }, + ) => true, + ( + 3, + WriteOp::Insert { + ns: Namespace::Device, + key, + .. + }, + ) => key == PENDING, + ( + 4, + WriteOp::Delete { + ns: Namespace::Device, + key, + }, + ) => key == PENDING, + ( + 5, + WriteOp::Put { + ns: Namespace::Device, + key, + .. + }, + ) => key == "prekey-upload-ack-v1", + _ => false, + }); + if fails { + return Err(StoreError::Validation( + "synthetic atomic commit failure".into(), + )); + } + let consumed = self.consume.swap(0, Ordering::SeqCst); + if consumed != 0 + && ops.iter().any(|op| { + matches!( + op, + WriteOp::Update { + ns: Namespace::PreKey, + .. + } + ) + }) + { + self.inner + .write( + fence, + &[WriteOp::Delete { + ns: Namespace::PreKey, + key: format!("{consumed:010}"), + }], + ) + .await?; + } + self.inner.write(fence, ops).await + } +} + +struct Sealing { + inner: AesGcmSealer, + fail_after: AtomicUsize, +} +#[async_trait] +impl Sealer for Sealing { + async fn seal(&self, aad: &[u8], plain: &[u8]) -> Result, SealError> { + let remaining = self.fail_after.load(Ordering::SeqCst); + if remaining != 0 && self.fail_after.fetch_sub(1, Ordering::SeqCst) == 1 { + return Err(SealError::Backend("synthetic sealer failure".into())); + } + self.inner.seal(aad, plain).await + } + async fn open(&self, aad: &[u8], sealed: &[u8]) -> Result, SealError> { + self.inner.open(aad, sealed).await + } +} +type Store = RecordSignalStore; +struct Fixture { + records: Arc, + sealer: Arc, + store: Arc, + ops: SignalOps, +} +impl Fixture { + async fn new() -> Self { + let records = Arc::new(Records::default()); + let sealer = Arc::new(Sealing { + inner: AesGcmSealer::new(&[42; 32]), + fail_after: AtomicUsize::new(0), + }); + let lease = records + .inner + .acquire(SCOPE, "holder-a", 0, 60_000) + .await + .unwrap() + .unwrap(); + let store = Arc::new(RecordSignalStore::new( + records.clone(), + sealer.clone(), + lease.fence, + )); + let ops = SignalOps::create(store.clone()).await.unwrap(); + Self { + records, + sealer, + store, + ops, + } + } + async fn counter(&self) -> u32 { + DeviceKeys::decode(&self.store.load_device().await.unwrap().unwrap()) + .unwrap() + .next_prekey_id + } + async fn set_counter(&self, counter: u32) { + let mut keys = + DeviceKeys::decode(&self.store.load_device().await.unwrap().unwrap()).unwrap(); + keys.next_prekey_id = counter; + self.store.save_device(&keys.encode()).await.unwrap(); + } + async fn uploaded(&self, id: u32) -> Option { + let key = format!("{id:010}"); + let sealed = self + .records + .get(SCOPE, Namespace::PreKey, &key) + .await + .unwrap()?; + let mut aad = Vec::new(); + for part in [ + b"wacore-recordstore/v1".as_slice(), + SCOPE.as_bytes(), + b"prekey", + key.as_bytes(), + ] { + aad.extend_from_slice(&(part.len() as u32).to_be_bytes()); + aad.extend_from_slice(part); + } + Some(self.sealer.open(&aad, &sealed).await.unwrap()[0] == 1) + } +} + +#[test] +fn reopen_reuses_the_batch_and_repeated_requests_do_not_allocate_more() { + block_on(async { + let f = Fixture::new().await; + let first = f.ops.prepare_prekey_upload(3).await.unwrap(); + assert_eq!(first.pre_keys.len(), 3); + for _ in 0..20 { + assert_eq!(f.ops.prepare_prekey_upload(812).await.unwrap(), first); + } + let reopened = SignalOps::open(f.store.clone()).await.unwrap(); + assert_eq!(reopened.prepare_prekey_upload(1).await.unwrap(), first); + assert_eq!(f.counter().await, 4); + assert_eq!( + f.records + .scan_keys(SCOPE, Namespace::PreKey, "", None) + .await + .unwrap() + .len(), + 3 + ); + let ids: Vec<_> = first.pre_keys.iter().map(|key| key.id).collect(); + reopened + .acknowledge_prekey_upload(&first.batch_id, &ids) + .await + .unwrap(); + for id in ids { + assert_eq!(f.uploaded(id).await, Some(true)); + } + reopened + .acknowledge_prekey_upload(&first.batch_id, &[1, 2, 3]) + .await + .unwrap(); + let next = reopened.prepare_prekey_upload(2).await.unwrap(); + assert_ne!(next.batch_id, first.batch_id); + assert_eq!( + next.pre_keys.iter().map(|key| key.id).collect::>(), + [4, 5] + ); + }); +} + +#[test] +fn counter_private_keys_and_pending_batch_fail_together_without_exposure() { + block_on(async { + for failure in 1..=3 { + let f = Fixture::new().await; + let before = f.store.load_device().await.unwrap(); + f.records.fail.store(failure, Ordering::SeqCst); + assert!(f.ops.prepare_prekey_upload(4).await.is_err()); + assert_eq!(f.store.load_device().await.unwrap(), before); + assert!( + f.store + .load_aux(Namespace::Device, PENDING) + .await + .unwrap() + .is_none() + ); + assert!( + f.records + .scan_keys(SCOPE, Namespace::PreKey, "", None) + .await + .unwrap() + .is_empty() + ); + let failed = f.records.batches.lock().unwrap().last().unwrap().clone(); + assert_eq!( + failed + .iter() + .filter(|op| matches!( + op, + WriteOp::Insert { + ns: Namespace::PreKey, + .. + } + )) + .count(), + 4 + ); + assert!(failed.iter().any( + |op| matches!(op, WriteOp::Put { ns: Namespace::Device, key, .. } if key == "local") + )); + assert!(failed.iter().any(|op| matches!(op, WriteOp::Insert { ns: Namespace::Device, key, .. } if key == PENDING))); + let reopened = SignalOps::open(f.store.clone()).await.unwrap(); + assert_eq!( + reopened.prepare_prekey_upload(1).await.unwrap().pre_keys[0].id, + 1 + ); + } + let f = Fixture::new().await; + f.sealer.fail_after.store(2, Ordering::SeqCst); + assert!(f.ops.prepare_prekey_upload(2).await.is_err()); + assert_eq!(f.counter().await, 1); + assert!(f.store.load_prekey(1).await.unwrap().is_none()); + assert!( + f.store + .load_aux(Namespace::Device, PENDING) + .await + .unwrap() + .is_none() + ); + assert_eq!( + f.ops.prepare_prekey_upload(2).await.unwrap().pre_keys[0].id, + 1 + ); + }); +} + +#[tokio::test] +async fn concurrent_prepare_is_bounded_and_retry_generation_stays_fresh() { + let f = Fixture::new().await; + let batches = futures::future::join_all((0..24).map(|_| f.ops.prepare_prekey_upload(8))).await; + let batch = batches[0].as_ref().unwrap(); + for result in &batches { + assert_eq!(result.as_ref().unwrap(), batch); + } + let retries = futures::future::join_all((0..12).map(|_| f.ops.generate_prekeys(2))).await; + let ids: std::collections::BTreeSet<_> = retries + .into_iter() + .flat_map(|keys| keys.unwrap().into_iter().map(|key| key.id)) + .collect(); + assert_eq!(ids.len(), 24); + assert!(batch.pre_keys.iter().all(|key| !ids.contains(&key.id))); + assert_eq!(f.counter().await, 33); +} + +#[test] +fn wrap_reserves_consumed_pending_ids_and_late_ack_never_marks_retry_keys() { + block_on(async { + let f = Fixture::new().await; + let old = f.ops.generate_prekeys(1).await.unwrap(); + let old_record = f.store.load_prekey(old[0].id).await.unwrap(); + f.set_counter(MAX - 1).await; + let batch = f.ops.prepare_prekey_upload(3).await.unwrap(); + let ids: Vec<_> = batch.pre_keys.iter().map(|key| key.id).collect(); + assert_eq!(ids, [MAX - 1, MAX, 2]); + f.store.remove_prekey(MAX - 1).await.unwrap(); + f.set_counter(MAX - 1).await; + let fresh = f.ops.generate_prekeys(3).await.unwrap(); + assert_eq!( + fresh.iter().map(|key| key.id).collect::>(), + [3, 4, 5] + ); + assert_eq!(f.store.load_prekey(1).await.unwrap(), old_record); + let filtered = f.ops.prepare_prekey_upload(3).await.unwrap(); + assert_eq!(filtered.batch_id, batch.batch_id); + assert_eq!( + filtered + .pre_keys + .iter() + .map(|key| key.id) + .collect::>(), + [MAX, 2] + ); + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &ids) + .await + .unwrap(); + assert!(f.store.load_prekey(MAX - 1).await.unwrap().is_none()); + for key in fresh { + assert_eq!(f.uploaded(key.id).await, Some(false)); + } + assert_eq!(f.uploaded(1).await, Some(false)); + assert_eq!(f.uploaded(MAX).await, Some(true)); + assert_eq!(f.uploaded(2).await, Some(true)); + }); +} + +#[test] +fn consumed_keys_stay_gone_even_between_ack_read_and_atomic_commit() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(3).await.unwrap(); + f.store.remove_prekey(1).await.unwrap(); + let filtered = f.ops.prepare_prekey_upload(3).await.unwrap(); + assert_eq!( + filtered + .pre_keys + .iter() + .map(|key| key.id) + .collect::>(), + [2, 3] + ); + f.records.consume.store(2, Ordering::SeqCst); + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[2, 3]) + .await + .unwrap(); + assert_eq!(f.uploaded(1).await, None); + assert_eq!(f.uploaded(2).await, None); + assert_eq!(f.uploaded(3).await, Some(true)); + assert!( + f.store + .load_aux(Namespace::Device, PENDING) + .await + .unwrap() + .is_none() + ); + }); +} + +#[test] +fn ack_rejects_unknown_duplicate_missing_and_stale_batches_and_recovers_failed_commit() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(2).await.unwrap(); + for (id, ids) in [ + ("unknown", vec![1, 2]), + (batch.batch_id.as_str(), vec![1]), + (batch.batch_id.as_str(), vec![1, 1, 2]), + (batch.batch_id.as_str(), vec![1, 2, 3]), + ] { + assert!(f.ops.acknowledge_prekey_upload(id, &ids).await.is_err()); + assert_eq!(f.ops.prepare_prekey_upload(2).await.unwrap(), batch); + assert_eq!(f.uploaded(1).await, Some(false)); + } + f.records.fail.store(4, Ordering::SeqCst); + assert!( + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .is_err() + ); + assert_eq!(f.uploaded(1).await, Some(false)); + let reopened = SignalOps::open(f.store.clone()).await.unwrap(); + assert_eq!(reopened.prepare_prekey_upload(2).await.unwrap(), batch); + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap(); + let next = reopened.prepare_prekey_upload(1).await.unwrap(); + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap(); + assert!( + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1]) + .await + .is_err() + ); + assert_eq!(reopened.prepare_prekey_upload(1).await.unwrap(), next); + reopened + .acknowledge_prekey_upload(&next.batch_id, &[next.pre_keys[0].id]) + .await + .unwrap(); + assert!( + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .is_err() + ); + }); +} + +#[test] +fn consumed_batch_retires_and_stale_holder_cannot_prepare_or_ack() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(1).await.unwrap(); + f.store.remove_prekey(batch.pre_keys[0].id).await.unwrap(); + let replacement = f.ops.prepare_prekey_upload(1).await.unwrap(); + assert_ne!(replacement.batch_id, batch.batch_id); + assert!( + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1]) + .await + .is_err() + ); + let lease = f + .records + .inner + .acquire(SCOPE, "holder-b", 60_001, 60_000) + .await + .unwrap() + .unwrap(); + assert!( + f.ops + .prepare_prekey_upload(1) + .await + .unwrap_err() + .is_fence_lost() + ); + assert!( + f.ops + .acknowledge_prekey_upload(&replacement.batch_id, &[replacement.pre_keys[0].id]) + .await + .unwrap_err() + .is_fence_lost() + ); + assert!(f.ops.generate_prekeys(1).await.unwrap_err().is_fence_lost()); + let store = Arc::new(RecordSignalStore::new( + f.records.clone(), + f.sealer.clone(), + lease.fence, + )); + let new = SignalOps::open(store).await.unwrap(); + assert_eq!(new.prepare_prekey_upload(1).await.unwrap(), replacement); + }); +} + +#[test] +fn corrupted_pending_metadata_or_private_keys_fail_closed_without_replacement() { + block_on(async { + for kind in 0..5 { + let f = Fixture::new().await; + f.ops.prepare_prekey_upload(2).await.unwrap(); + let mut pending = f + .store + .load_aux(Namespace::Device, PENDING) + .await + .unwrap() + .unwrap(); + match kind { + 0 => pending[0] = 99, + 1 => { + pending.pop(); + } + 2 => pending[19..23].copy_from_slice(&0u32.to_be_bytes()), + 3 => { + let entry = pending[19..55].to_vec(); + pending[55..91].copy_from_slice(&entry); + } + _ => pending[23] ^= 1, + } + if kind >= 2 { + use sha2::Digest; + let data_len = pending.len() - 32; + let digest = sha2::Sha256::digest(&pending[..data_len]); + pending[data_len..].copy_from_slice(&digest); + } + f.store + .put_aux(Namespace::Device, PENDING, &pending) + .await + .unwrap(); + let before = f.store.load_device().await.unwrap(); + assert!(f.ops.prepare_prekey_upload(2).await.is_err()); + assert!(f.ops.generate_prekeys(1).await.is_err()); + assert_eq!(f.store.load_device().await.unwrap(), before); + } + let f = Fixture::new().await; + f.ops.prepare_prekey_upload(1).await.unwrap(); + f.store + .store_prekey(1, b"corrupt-record", false) + .await + .unwrap(); + assert!(f.ops.prepare_prekey_upload(1).await.is_err()); + for count in [0, 813, u32::MAX] { + assert!(f.ops.prepare_prekey_upload(count).await.is_err()); + assert!(f.ops.generate_prekeys(count).await.is_err()); + } + }); +} + +#[test] +fn lost_ack_response_replays_after_restart_without_touching_new_pending_or_crossing_fence() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(2).await.unwrap(); + f.records.fail.store(5, Ordering::SeqCst); + assert!( + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .is_err() + ); + assert_eq!(f.uploaded(1).await, Some(false)); + assert_eq!(f.ops.prepare_prekey_upload(2).await.unwrap(), batch); + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap(); + let reopened = SignalOps::open(f.store.clone()).await.unwrap(); + let next = reopened.prepare_prekey_upload(1).await.unwrap(); + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[2, 1]) + .await + .unwrap(); + assert_eq!(reopened.prepare_prekey_upload(1).await.unwrap(), next); + assert!( + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 1, 2]) + .await + .is_err() + ); + f.records + .inner + .acquire(SCOPE, "holder-b", 60_001, 60_000) + .await + .unwrap() + .unwrap(); + assert!( + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap_err() + .is_fence_lost() + ); + }); +} + +#[test] +fn allocation_work_is_bounded_and_collision_refuses_the_entire_staged_batch() { + block_on(async { + let f = Fixture::new().await; + let occupied: Vec<_> = (1..=4096) + .map(|id| (id, bytes::Bytes::from_static(b"occupied-private-slot"))) + .collect(); + f.store.store_prekeys_batch(&occupied, false).await.unwrap(); + let before = f.store.load_device().await.unwrap(); + f.records.reads.store(0, Ordering::SeqCst); + let error = f.ops.prepare_prekey_upload(1).await.unwrap_err(); + assert!(error.to_string().contains("4096-ID scan limit")); + assert!(f.records.reads.load(Ordering::SeqCst) <= 4098); + assert_eq!(f.store.load_device().await.unwrap(), before); + assert!( + f.store + .load_aux(Namespace::Device, PENDING) + .await + .unwrap() + .is_none() + ); + let staged = f.store.staged(); + staged + .insert_prekeys_batch(&[ + (5000, bytes::Bytes::from_static(b"new")), + (1, bytes::Bytes::from_static(b"replacement")), + ]) + .await + .unwrap(); + let mut keys = DeviceKeys::decode(before.as_deref().unwrap()).unwrap(); + keys.next_prekey_id = 5001; + staged.save_device(&keys.encode()).await.unwrap(); + assert!(wacore_recordstore::is_record_exists( + &staged.commit().await.unwrap_err() + )); + assert_eq!(f.store.load_device().await.unwrap(), before); + assert!(f.store.load_prekey(5000).await.unwrap().is_none()); + assert_eq!( + f.store.load_prekey(1).await.unwrap().unwrap(), + occupied[0].1 + ); + }); +} + +#[test] +fn actual_signal_consumption_filters_the_recovered_public_batch() { + block_on(async { + use wacore::libsignal::protocol::{DeviceId, ProtocolAddress}; + use wacore_signal_ops::RemoteBundle; + let sender = Fixture::new().await; + let receiver = Fixture::new().await; + let batch = receiver.ops.prepare_prekey_upload(2).await.unwrap(); + let identity = receiver.ops.public_identity().await; + let sender_address = ProtocolAddress::new("111@s.whatsapp.net", DeviceId::from(1)); + let receiver_address = ProtocolAddress::new("222@s.whatsapp.net", DeviceId::from(1)); + sender + .ops + .establish_session( + &receiver_address, + &RemoteBundle { + registration_id: identity.registration_id, + device_id: 1, + identity_key: identity.identity_key, + signed_prekey_id: identity.signed_prekey.id, + signed_prekey: identity.signed_prekey.public_key, + signed_prekey_signature: identity.signed_prekey.signature, + prekey: Some((batch.pre_keys[0].id, batch.pre_keys[0].public_key)), + }, + ) + .await + .unwrap(); + let sent = sender + .ops + .encrypt(&receiver_address, b"synthetic-upload-roundtrip") + .await + .unwrap(); + let received = receiver + .ops + .decrypt(&sender_address, sent.kind, &sent.ciphertext) + .await + .unwrap(); + assert_eq!(received.plaintext, b"synthetic-upload-roundtrip"); + let reopened = SignalOps::open(receiver.store.clone()).await.unwrap(); + let filtered = reopened.prepare_prekey_upload(2).await.unwrap(); + assert_eq!(filtered.batch_id, batch.batch_id); + assert_eq!(filtered.pre_keys, batch.pre_keys[1..]); + reopened + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap(); + assert_eq!(receiver.uploaded(1).await, None); + assert_eq!(receiver.uploaded(2).await, Some(true)); + }); +} + +#[test] +fn upload_ceiling_and_corrupt_ack_receipt_are_enforced() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(812).await.unwrap(); + assert_eq!(batch.pre_keys.len(), 812); + f.ops + .acknowledge_prekey_upload( + &batch.batch_id, + &batch.pre_keys.iter().map(|key| key.id).collect::>(), + ) + .await + .unwrap(); + f.store + .put_aux( + Namespace::Device, + "prekey-upload-ack-v1", + b"corrupt-receipt", + ) + .await + .unwrap(); + assert!( + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1]) + .await + .is_err() + ); + assert_eq!(f.counter().await, 813); + }); +} From 47fa2ee9d3701e4d4d51db72e3a462c0c0269017 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 15 Oct 2026 07:21:37 +0300 Subject: [PATCH 15/17] Simplify equivalent sender-key target predicates for Clippy --- wacore/signal-ops/src/outbound.rs | 6 +++--- wacore/src/send/group.rs | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/wacore/signal-ops/src/outbound.rs b/wacore/signal-ops/src/outbound.rs index 8f734240..ecf8482e 100644 --- a/wacore/signal-ops/src/outbound.rs +++ b/wacore/signal-ops/src/outbound.rs @@ -383,11 +383,11 @@ impl SignalOps { .devices() .iter() .filter(|device| { - !device.is_hosted() - && !(device.user == own_sending.user && device.device == own_sending.device) + !(device.is_hosted() + || device.user == own_sending.user && device.device == own_sending.device // WA Web treats a companion as warm only if its // primary (device 0) is too. - && !(is_warm(device) && is_warm(&device.with_device(0))) + || is_warm(device) && is_warm(&device.with_device(0))) }) .cloned() .collect() diff --git a/wacore/src/send/group.rs b/wacore/src/send/group.rs index f099c6c3..680d501c 100644 --- a/wacore/src/send/group.rs +++ b/wacore/src/send/group.rs @@ -7,8 +7,8 @@ use super::*; /// by both normal and targeted sends. pub fn retain_skdm_distribution_targets(devices: &mut Vec, own_sending_jid: &Jid) { devices.retain(|device| { - !(device.user == own_sending_jid.user && device.device == own_sending_jid.device) - && !device.is_hosted() + !(device.user == own_sending_jid.user && device.device == own_sending_jid.device + || device.is_hosted()) }); } From 7389145852023f3b3fa0750091322ee012724213 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 15 Oct 2026 07:48:01 +0300 Subject: [PATCH 16/17] feat(signal-wasm): expose durable uploads and complete receive retries --- .github/workflows/wasm.yml | 9 + Cargo.lock | 5 + wacore/recordstore/src/signal.rs | 31 + wacore/signal-wasm/Cargo.toml | 11 + wacore/signal-wasm/examples/fixture.rs | 96 +++ wacore/signal-wasm/examples/upload.mjs | 47 ++ wacore/signal-wasm/src/adapters.rs | 5 + wacore/signal-wasm/src/convert.rs | 68 +- wacore/signal-wasm/src/lib.rs | 183 +++++- wacore/signal-wasm/tests/node.test.mjs | 846 ++++++++++++++++++++++++- wacore/signal-wasm/tests/types.ts | 71 +++ 11 files changed, 1332 insertions(+), 40 deletions(-) create mode 100644 wacore/signal-wasm/examples/fixture.rs create mode 100644 wacore/signal-wasm/examples/upload.mjs create mode 100644 wacore/signal-wasm/tests/types.ts diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index 6c9a4693..f11ccecc 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -109,14 +109,23 @@ jobs: uses: taiki-e/install-action@v2 with: tool: wasm-bindgen@0.2.128 + - name: Use Node 24 for JavaScript binding tests + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7 + with: + node-version: "24" + package-manager-cache: false - name: Test wacore-signal-wasm bindings from Node + # Build the native fixture without the WASM-only randomness override. env: RUSTFLAGS: '--cfg getrandom_backend="wasm_js"' + SIGNAL_WASM_FIXTURE: ${{ github.workspace }}/target/debug/examples/fixture run: | + RUSTFLAGS='' cargo build --locked -p wacore-signal-wasm --example fixture cargo build -p wacore-signal-wasm --lib --release --target wasm32-unknown-unknown wasm-bindgen --target nodejs --out-dir target/signal-wasm-node \ target/wasm32-unknown-unknown/release/wacore_signal_wasm.wasm node --test wacore/signal-wasm/tests/node.test.mjs + npm exec --yes --package=typescript@6.0.3 -- tsc --noEmit --target es2022 --module esnext --moduleResolution bundler --lib esnext,dom --strict wacore/signal-wasm/tests/types.ts - name: Guard sqlite-storage's platform shim run: cargo xt ci workflow guard-sqlite-shim # The sans-IO VoIP core (wacore::voip: the CallEngine, the portable run_call driver, diff --git a/Cargo.lock b/Cargo.lock index 0cb2fda9..315a4522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5364,9 +5364,14 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "buffa", "getrandom 0.4.3", + "hmac 0.13.0", "js-sys", + "rand 0.10.3", "send_wrapper", + "serde_json", + "sha2 0.11.0", "wacore", "wacore-binary", "wacore-recordstore", diff --git a/wacore/recordstore/src/signal.rs b/wacore/recordstore/src/signal.rs index c70f25b6..16e836b2 100644 --- a/wacore/recordstore/src/signal.rs +++ b/wacore/recordstore/src/signal.rs @@ -73,6 +73,35 @@ impl RecordSignalStore { &self.fence } + /// Count surviving prekeys marked uploaded, refusing a superseded fence. + /// Missing records consumed during this read are skipped. This is a + /// count of local state, not confirmation of the provider's current pool. + pub async fn uploaded_prekey_count(&self) -> Result { + self.records.write(&self.fence, &[]).await?; + let keys = self.scan(Namespace::PreKey, "", None).await?; + let mut count = 0u32; + for chunk in keys.chunks(128) { + let refs: Vec<_> = chunk.iter().map(String::as_str).collect(); + for (_, value) in self.load_many(Namespace::PreKey, &refs).await? { + match value.first() { + Some(&PREKEY_UPLOADED) => { + count = count + .checked_add(1) + .ok_or_else(|| StoreError::Validation("prekey count overflow".into()))? + } + Some(&PREKEY_NOT_UPLOADED) => {} + _ => { + return Err(StoreError::Validation( + "prekey record has no upload flag".into(), + )); + } + } + } + } + self.records.write(&self.fence, &[]).await?; + Ok(count) + } + /// A view over the same scope whose writes are held until [`Self::commit`]. /// Reads see the view's own pending writes first. pub fn staged(&self) -> Self { @@ -903,10 +932,12 @@ mod tests { (100, Bytes::from_static(b"hundred")), ]; block_on(store.store_prekeys_batch(&keys, false)).unwrap(); + assert_eq!(block_on(store.uploaded_prekey_count()).unwrap(), 0); assert_eq!(block_on(store.get_max_prekey_id()).unwrap(), 100); block_on(store.remove_prekey(10)).unwrap(); block_on(store.mark_prekeys_uploaded(&[9, 10])).unwrap(); + assert_eq!(block_on(store.uploaded_prekey_count()).unwrap(), 1); assert_eq!(block_on(store.load_prekey(10)).unwrap(), None); assert_eq!( block_on(store.load_prekey(9)).unwrap().as_deref(), diff --git a/wacore/signal-wasm/Cargo.toml b/wacore/signal-wasm/Cargo.toml index add98a62..5a4460fc 100644 --- a/wacore/signal-wasm/Cargo.toml +++ b/wacore/signal-wasm/Cargo.toml @@ -17,6 +17,7 @@ crate-type = ["cdylib", "rlib"] [target.'cfg(target_arch = "wasm32")'.dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +buffa = { workspace = true } js-sys = "0.3.105" send_wrapper = "0.6" wacore = { workspace = true } @@ -29,3 +30,13 @@ waproto = { workspace = true } [target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] getrandom = { workspace = true, features = ["wasm_js"] } + +[dev-dependencies] +buffa = { workspace = true } +hmac = { workspace = true } +sha2 = { workspace = true } +rand = { workspace = true } +serde_json = { workspace = true } +wacore = { workspace = true, features = ["test-util"] } +wacore-binary = { workspace = true } +waproto = { workspace = true } diff --git a/wacore/signal-wasm/examples/fixture.rs b/wacore/signal-wasm/examples/fixture.rs new file mode 100644 index 00000000..f46e3ae8 --- /dev/null +++ b/wacore/signal-wasm/examples/fixture.rs @@ -0,0 +1,96 @@ +//! Synthetic primary-device signing and stanza inspection for Node tests. +//! Input stays on stdin; output contains only a public signed container or +//! ciphertext stanza metadata. This is not an application service. +use hmac::{KeyInit, Mac}; +use serde_json::{Value, json}; +use std::io; +use wacore_binary::{Node, NodeContent}; + +fn bytes(value: &Value, name: &str) -> Result, Box> { + Ok(serde_json::from_value(value[name].clone())?) +} + +// Test-only protos have no pinned codec root, as in the native pairing tests. +#[allow(clippy::disallowed_methods)] +fn pairing(value: &Value) -> Result> { + use buffa::Message as _; + use wacore::libsignal::protocol::{KeyPair, PublicKey}; + use waproto::whatsapp as wa; + let public: [u8; 32] = bytes(value, "identityKey")? + .try_into() + .map_err(|_| "invalid synthetic key length")?; + let adv: [u8; 32] = bytes(value, "advSecret")? + .try_into() + .map_err(|_| "invalid synthetic key length")?; + let mut rng = rand::make_rng::(); + let account = KeyPair::generate(&mut rng); + let device = KeyPair::new( + PublicKey::from_djb_public_key_bytes(&public)?, + KeyPair::generate(&mut rng).private_key, + ); + let details = wa::ADVDeviceIdentity { + raw_id: Some(1), + timestamp: Some(0), + key_index: Some(1), + account_type: Some(wa::ADVEncryptionType::E2EE), + device_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + let signed = wacore::adv::test_util::signed_identity( + &account, + &device, + &details, + wacore::adv::test_util::account_prefix(Some(wa::ADVEncryptionType::E2EE)), + None, + true, + ) + .encode_to_vec(); + let mut mac = as KeyInit>::new_from_slice(&adv)?; + mac.update(&signed); + let container = wa::ADVSignedDeviceIdentityHMAC { + details: Some(signed), + hmac: Some(mac.finalize().into_bytes().to_vec()), + account_type: Some(wa::ADVEncryptionType::E2EE), + } + .encode_to_vec(); + Ok(json!({"container":container})) +} + +fn node(value: &Node) -> Value { + let attrs: std::collections::BTreeMap<_, _> = value + .attrs + .iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + let data = match &value.content { + Some(NodeContent::Bytes(bytes)) => Some(bytes), + _ => None, + }; + let children: Vec<_> = value + .children() + .unwrap_or_default() + .iter() + .map(node) + .collect(); + json!({"tag":value.tag, "attrs":attrs, "data":data, "children":children}) +} + +fn run() -> Result<(), Box> { + let input: Value = serde_json::from_reader(io::stdin().lock())?; + let output = match input["op"].as_str() { + Some("pairing") => pairing(&input)?, + Some("stanza") => node( + &wacore_binary::marshal::unmarshal_packed_ref(&bytes(&input, "stanza")?)?.to_owned(), + ), + _ => return Err("unknown synthetic fixture operation".into()), + }; + serde_json::to_writer(io::stdout().lock(), &output)?; + Ok(()) +} + +fn main() { + if run().is_err() { + eprintln!("synthetic fixture request failed"); + std::process::exit(1); + } +} diff --git a/wacore/signal-wasm/examples/upload.mjs b/wacore/signal-wasm/examples/upload.mjs new file mode 100644 index 00000000..49f24846 --- /dev/null +++ b/wacore/signal-wasm/examples/upload.mjs @@ -0,0 +1,47 @@ +// The customer initializes its own wasm-bindgen module and supplies durable +// fenced storage plus an AAD-authenticating sealer. Keep one device per held +// scope. The callback owns provider transport, retries and deadlines. +export async function uploadPublicPrekeys( + wasm, + { backend, sealer, scope, holder, nowMs, ttlMs, count = 812 }, + upload, +) { + if (typeof wasm?.SignalDevice?.open !== "function") + throw new TypeError( + "An initialized wasm-bindgen module exposing SignalDevice is required.", + ); + const device = await wasm.SignalDevice.open( + backend, + sealer, + scope, + holder, + nowMs, + ttlMs, + true, + ); + try { + const batch = await device.preparePrekeyUpload(count); + const { registrationId, identityKey, signedPrekey } = + await device.publicIdentity(); + // ADV pairing material is absent from the upload callback. A failed/lost + // response leaves the durable batch available when the caller reopens it. + await upload({ + registrationId, + identityKey, + signedPrekey, + batchId: batch.batchId, + preKeys: batch.preKeys, + }); + await device.acknowledgePrekeyUpload( + batch.batchId, + Uint32Array.from(batch.preKeys.map((key) => key.id)), + ); + return batch.batchId; + } finally { + try { + await device.release(); + } finally { + device.free(); + } + } +} diff --git a/wacore/signal-wasm/src/adapters.rs b/wacore/signal-wasm/src/adapters.rs index 6a49946d..8a1c2730 100644 --- a/wacore/signal-wasm/src/adapters.rs +++ b/wacore/signal-wasm/src/adapters.rs @@ -67,6 +67,11 @@ extern "C" { #[wasm_bindgen(typescript_type = "Sealer")] pub type Sealer; + // An opaque JS value keeps bool consent strict while retaining an + // optional boolean in generated TypeScript (no truthiness coercion). + #[wasm_bindgen(typescript_type = "boolean")] + pub type AppStateSharingConsent; + #[wasm_bindgen(method, catch)] fn seal( this: &Sealer, diff --git a/wacore/signal-wasm/src/convert.rs b/wacore/signal-wasm/src/convert.rs index 2b4a985e..31a0dcf0 100644 --- a/wacore/signal-wasm/src/convert.rs +++ b/wacore/signal-wasm/src/convert.rs @@ -152,7 +152,7 @@ pub fn remote_bundle(value: &JsValue) -> Result { /// Generations and expiry times travel as JS numbers; both stay far below /// 2^53 in practice, and the conversion rejects anything that is not. -fn safe_u64(value: f64, what: &str) -> Result { +pub fn safe_u64(value: f64, what: &str) -> Result { if value.fract() != 0.0 || !(0.0..=9_007_199_254_740_991.0).contains(&value) { return Err(format!("{what} must be a non-negative safe integer")); } @@ -233,3 +233,69 @@ pub fn message(bytes: &[u8]) -> Result { waproto::codec::message_decode(bytes) .map_err(|e| invalid(&format!("message is not an encoded waE2E.Message: {e}"))) } + +/// This non-hot protocol root has no pinned waproto codec entry point. +#[allow(clippy::disallowed_methods)] +pub fn app_state_share(value: &waproto::whatsapp::message::AppStateSyncKeyShare) -> Vec { + use buffa::Message as _; + value.encode_to_vec() +} + +pub struct ParsedRetry { + pub chat: wacore_binary::Jid, + pub message_id: String, + pub requester: wacore_binary::Jid, + pub encryption_jid: wacore_binary::Jid, + pub retry_count: u8, + pub route: wacore_signal_ops::RetryRoute, + pub bundle: Option, +} + +fn string_field(value: &JsValue, name: &str) -> Result { + get(value, name) + .as_string() + .filter(|v| !v.is_empty()) + .ok_or_else(|| invalid(&format!("{name} must be a nonempty string"))) +} + +pub fn retry_request(value: &JsValue) -> Result { + use wacore::types::message::AddressingMode; + use wacore_signal_ops::RetryRoute; + let route = get(value, "route"); + let route = match string_field(&route, "kind")?.as_str() { + "direct" => { + let recipient = get(&route, "recipient"); + RetryRoute::Direct { + recipient: if recipient.is_null() || recipient.is_undefined() { + None + } else { + Some(jid(&string_field(&route, "recipient")?)?) + }, + } + } + "group" => RetryRoute::Group { + addressing_mode: match string_field(&route, "addressingMode")?.as_str() { + "pn" => AddressingMode::Pn, + "lid" => AddressingMode::Lid, + _ => return Err(invalid("addressingMode must be pn or lid")), + }, + }, + "broadcast_list" => RetryRoute::BroadcastList, + _ => return Err(invalid("retry route kind is invalid")), + }; + let bundle = get(value, "bundle"); + Ok(ParsedRetry { + chat: jid(&string_field(value, "chat")?)?, + message_id: string_field(value, "messageId")?, + requester: jid(&string_field(value, "requester")?)?, + encryption_jid: jid(&string_field(value, "encryptionJid")?)?, + retry_count: u8::try_from(u32_field(value, "retryCount")?) + .map_err(|_| invalid("retryCount must fit in 8 bits"))?, + route, + bundle: if bundle.is_null() || bundle.is_undefined() { + None + } else { + Some(remote_bundle(&bundle)?) + }, + }) +} diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index bc1ac864..4e79f20d 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -3,6 +3,14 @@ //! The page supplies storage and sealing: a `RecordBackend` (IndexedDB in a //! browser, anything in an edge worker) and a `Sealer` (WebCrypto, so the data //! key can stay non-extractable). This crate supplies the Signal state machine. +//! Supply an initialized wasm-bindgen module exposing `SignalDevice`, not a +//! bare WebAssembly instance. Keep one device per held scope. Neither this +//! module nor its local Node tests establish a browser transport or grant flow. +//! App-state sharing is a separate default-off choice: enabling it permits +//! contact-name, label and mute/archive/pin metadata decryption. Message +//! plaintext custody does not imply metadata opacity. Protect the caller's +//! store/sealer, renew its lease, and persist application content before +//! `markDelivered`. The host still owns history consent and transport policy. #![cfg(target_arch = "wasm32")] mod adapters; @@ -63,6 +71,21 @@ export interface RemoteBundle { prekeyId?: number; prekey?: Uint8Array; } +export interface PublicPreKey { id: number; publicKey: Uint8Array } +export interface PrekeyUploadBatch { batchId: string; preKeys: PublicPreKey[] } +export type RetryRoute = + | { kind: "direct"; recipient?: string } + | { kind: "group"; addressingMode: "pn" | "lid" } + | { kind: "broadcast_list" }; +export interface RetryRequest { + chat: string; messageId: string; requester: string; encryptionJid: string; + retryCount: number; route: RetryRoute; bundle?: RemoteBundle; +} +export type ReceiveResult = + | { status: "already_delivered"; receiptKey: string } + | { status: "message"; receiptKey: string; message: Uint8Array; + isSkdmOnly: boolean; identityChanged: boolean; redelivered: boolean; + senderKeyRejected: boolean; appStateKeyShare: Uint8Array }; "#; type Store = RecordSignalStore; @@ -71,6 +94,7 @@ struct Inner { ops: SignalOps, records: Arc, lease: RefCell, + store: Arc, } /// One linked device's Signal state, held under a lease. @@ -114,7 +138,7 @@ impl SignalDevice { )); let opened = match SignalOps::open(store.clone()).await { Ok(ops) => Ok(ops), - Err(OpsError::NotInitialised) if create => SignalOps::create(store).await, + Err(OpsError::NotInitialised) if create => SignalOps::create(store.clone()).await, Err(e) => Err(e), }; let ops = match opened { @@ -133,6 +157,7 @@ impl SignalDevice { ops, records, lease: RefCell::new(lease), + store, }), }) } @@ -194,6 +219,60 @@ impl SignalDevice { }) } + /// Durable public upload batch. Repeated calls/reopening recover the + /// outstanding live batch; use fresh `generatePrekeys` for retry receipts. + #[wasm_bindgen(js_name = preparePrekeyUpload, unchecked_return_type = "Promise")] + pub fn prepare_prekey_upload(&self, count: f64) -> Promise { + let inner = self.inner.clone(); + promise(async move { + if count.fract() != 0.0 || !(1.0..=812.0).contains(&count) { + return Err(convert::coded_error( + "invalid_input", + "prekey count must be an integer from 1 to 812", + )); + } + let batch = inner + .ops + .prepare_prekey_upload(count as u32) + .await + .map_err(ops_error)?; + let out = Object::new(); + set(&out, "batchId", &JsValue::from_str(&batch.batch_id)); + set(&out, "preKeys", &public_prekeys(&batch.pre_keys)); + Ok(out.into()) + }) + } + + /// Confirm a successful upload by its exact batch and member IDs. The + /// latest exact ACK replays safely after a lost response/reopening. + #[wasm_bindgen(js_name = acknowledgePrekeyUpload, unchecked_return_type = "Promise")] + pub fn acknowledge_prekey_upload(&self, batch_id: String, ids: Uint32Array) -> Promise { + let inner = self.inner.clone(); + let ids = ids.to_vec(); + promise(async move { + inner + .ops + .acknowledge_prekey_upload(&batch_id, &ids) + .await + .map_err(ops_error)?; + Ok(JsValue::UNDEFINED) + }) + } + + /// Local surviving uploaded keys; this does not query the provider pool. + #[wasm_bindgen(js_name = uploadedPrekeyCount, unchecked_return_type = "Promise")] + pub fn uploaded_prekey_count(&self) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let count = inner + .store + .uploaded_prekey_count() + .await + .map_err(|e| ops_error(OpsError::Store(e)))?; + Ok(JsValue::from_f64(f64::from(count))) + }) + } + #[wasm_bindgen(js_name = markPrekeysUploaded)] pub fn mark_prekeys_uploaded(&self, ids: Uint32Array) -> Promise { let inner = self.inner.clone(); @@ -371,9 +450,13 @@ impl SignalDevice { /// Decrypt, buffer and classify one received payload. Resolves /// `{ status: "message", receiptKey, message, isSkdmOnly, identityChanged, - /// redelivered }` where `message` is the encoded, unpadded `waE2E.Message`, + /// redelivered, senderKeyRejected, appStateKeyShare }` where `message` is + /// the encoded, unpadded `waE2E.Message` without app-state key fields, /// or `{ status: "already_delivered", receiptKey }`. Call `markDelivered` - /// once the app has stored the message. + /// once the app has stored the message. `share_app_state_keys` defaults + /// off; enabling it exposes own-device app-state keys separately, allowing + /// synchronized metadata decryption. It never enables foreign-device shares. + #[wasm_bindgen(unchecked_return_type = "Promise")] #[allow(clippy::too_many_arguments)] pub fn receive( &self, @@ -384,9 +467,19 @@ impl SignalDevice { ciphertext: Vec, padding_version: u8, is_from_me: bool, + share_app_state_keys: Option, ) -> Promise { let inner = self.inner.clone(); promise(async move { + let share_app_state_keys = match share_app_state_keys { + None => false, + Some(value) => value.as_bool().ok_or_else(|| { + convert::coded_error( + "invalid_input", + "app-state sharing consent must be boolean", + ) + })?, + }; let kind = match kind.as_str() { "pkmsg" => ReceiveKind::PreKey, "msg" => ReceiveKind::Message, @@ -417,9 +510,19 @@ impl SignalDevice { set(&out, "status", &JsValue::from_str("already_delivered")); set(&out, "receiptKey", &JsValue::from_str(&receipt_key)); } - Received::Message(message) => { + Received::Message(mut message) => { set(&out, "status", &JsValue::from_str("message")); set(&out, "receiptKey", &JsValue::from_str(&message.receipt_key)); + if let Some(protocol) = message.content.message.protocol_message.as_option_mut() + { + protocol.app_state_sync_key_share = Default::default(); + protocol.app_state_sync_key_request = Default::default(); + } + message.content.message.sender_key_distribution_message = Default::default(); + message + .content + .message + .fast_ratchet_key_sender_key_distribution_message = Default::default(); let encoded = waproto::codec::message_to_vec(&message.content.message); set(&out, "message", &Uint8Array::from(encoded.as_slice())); set( @@ -437,6 +540,25 @@ impl SignalDevice { "redelivered", &JsValue::from_bool(message.redelivered), ); + set( + &out, + "senderKeyRejected", + &JsValue::from_bool(message.sender_key_rejected), + ); + let share = if share_app_state_keys && is_from_me { + message + .app_state_key_share + .as_ref() + .map(convert::app_state_share) + .unwrap_or_default() + } else { + Vec::new() + }; + set( + &out, + "appStateKeyShare", + &Uint8Array::from(share.as_slice()), + ); } } Ok(out.into()) @@ -599,6 +721,59 @@ impl SignalDevice { }) } + /// Rebuild a stored sent message for a retry receipt. Only ciphertext + /// stanza bytes leave this method. The caller sends them unchanged and + /// applies its transport admission/pacing before sending. + #[wasm_bindgen(unchecked_return_type = "Promise")] + pub fn resend( + &self, + resolver: send::SendResolver, + #[wasm_bindgen(unchecked_param_type = "RetryRequest")] request: JsValue, + ) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let request = convert::retry_request(&request)?; + let resolver = send::JsResolver::new(resolver); + let stanza = inner + .ops + .resend( + &resolver, + wacore_signal_ops::RetryRequest { + chat: &request.chat, + message_id: &request.message_id, + requester: &request.requester, + encryption_jid: &request.encryption_jid, + route: request.route, + retry_count: request.retry_count, + bundle: request.bundle.as_ref(), + }, + ) + .await + .map_err(ops_error)?; + Ok(Uint8Array::from(stanza.as_slice()).into()) + }) + } + + /// Retire encrypted local retry records before this Unix-ms cutoff. + #[wasm_bindgen(js_name = pruneSentMessages, unchecked_return_type = "Promise")] + pub fn prune_sent_messages(&self, cutoff_ms: f64) -> Promise { + let inner = self.inner.clone(); + promise(async move { + let cutoff = convert::safe_u64(cutoff_ms, "cutoffMs").map_err(|_| { + convert::coded_error( + "invalid_input", + "cutoffMs must be a non-negative safe integer", + ) + })?; + let count = inner + .ops + .prune_sent_messages(cutoff) + .await + .map_err(ops_error)?; + Ok(JsValue::from_f64(count as f64)) + }) + } + /// Drop the identity and sessions of every device of a user whose /// identity changed, as reported by the server. #[wasm_bindgen(js_name = forgetUser)] diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index 99e589fd..dbc0318c 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -14,15 +14,99 @@ import assert from "node:assert/strict"; import { createRequire } from "node:module"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { mkdir, open } from "node:fs/promises"; +import { join } from "node:path"; +import { uploadPublicPrekeys } from "../examples/upload.mjs"; const require = createRequire(import.meta.url); const pkg = process.env.SIGNAL_WASM_PKG ?? fileURLToPath( - new URL("../../../target/signal-wasm-node/wacore_signal_wasm.js", import.meta.url), + new URL( + "../../../target/signal-wasm-node/wacore_signal_wasm.js", + import.meta.url, + ), ); const { SignalDevice } = require(pkg); +function fixture(input) { + assert.equal( + typeof process.env.SIGNAL_WASM_FIXTURE, + "string", + "build the native synthetic fixture helper first", + ); + const result = spawnSync(process.env.SIGNAL_WASM_FIXTURE, [], { + input: JSON.stringify(input), + maxBuffer: 2 * 1024 * 1024, + }); + assert.equal(result.status, 0, "synthetic fixture helper failed"); + return JSON.parse(result.stdout); +} +async function pair(signal) { + const identity = await signal.publicIdentity(); + const { container } = fixture({ + op: "pairing", + identityKey: [...identity.identityKey], + advSecret: [...identity.advSecret], + }); + await signal.signPairing(Uint8Array.from(container)); +} +const stanza = (bytes) => fixture({ op: "stanza", stanza: [...bytes] }); +function find(node, tag) { + return node.tag === tag + ? node + : node.children.map((n) => find(n, tag)).find(Boolean); +} +async function receiveStanza(signal, encoded, chat = ALICE) { + const enc = find(stanza(encoded), "enc"); + assert.ok(enc, "ciphertext stanza has enc"); + return signal.receive( + chat, + ALICE, + 1, + enc.attrs.type, + Uint8Array.from(enc.data), + Number(enc.attrs.v), + false, + ); +} +async function persistThenMark(signal, result) { + const dir = join( + process.env.SIGNAL_WASM_TEST_OUTPUT ?? "target/signal-wasm-node-proof", + crypto.randomUUID(), + ); + await mkdir(dir, { recursive: true }); + const file = await open(join(dir, "message.pb"), "wx"); + try { + await file.writeFile(result.message); + await file.sync(); + } finally { + await file.close(); + } + const directory = await open(dir, "r"); + try { + await directory.sync(); + } finally { + await directory.close(); + } + await signal.markDelivered(result.receiptKey); +} +function resolverFor(bundle, group = "120363000000001@g.us") { + return { + async resolveDevices() { + return ["222:1@s.whatsapp.net"]; + }, + async fetchPrekeys(jids) { + return Object.fromEntries(jids.map((jid) => [jid, bundle])); + }, + async resolveGroup(jid) { + assert.equal(jid, group); + return { participants: [ALICE, BOB], addressingMode: "pn" }; + }, + }; +} + const TTL = 60_000; class MemoryBackend { @@ -51,13 +135,19 @@ class MemoryBackend { throw Object.assign(new Error("fence lost"), { code: "fence_lost" }); } for (const op of ops) { - if (op.op === "insert" && this.records.has(this.#slot(fence.scope, op.ns, op.key))) { - throw Object.assign(new Error("record exists"), { code: "record_exists" }); + if ( + op.op === "insert" && + this.records.has(this.#slot(fence.scope, op.ns, op.key)) + ) { + throw Object.assign(new Error("record exists"), { + code: "record_exists", + }); } } for (const op of ops) { const slot = this.#slot(fence.scope, op.ns, op.key); - if (op.op === "put" || op.op === "insert") this.records.set(slot, op.value.slice()); + if (op.op === "put" || op.op === "insert") + this.records.set(slot, op.value.slice()); else if (op.op === "update") { if (this.records.has(slot)) this.records.set(slot, op.value.slice()); } else this.records.delete(slot); @@ -78,14 +168,23 @@ class MemoryBackend { async renew(lease, nowMs, ttlMs) { const row = this.leases.get(lease.scope); - if (!row || row.generation !== lease.generation || row.holder !== lease.holder) return null; + if ( + !row || + row.generation !== lease.generation || + row.holder !== lease.holder + ) + return null; row.expiresAtMs = nowMs + ttlMs; return { ...row }; } async release(lease) { const row = this.leases.get(lease.scope); - if (row && row.generation === lease.generation && row.holder === lease.holder) { + if ( + row && + row.generation === lease.generation && + row.holder === lease.holder + ) { row.expiresAtMs = 0; row.holder = ""; } @@ -93,20 +192,43 @@ class MemoryBackend { containsBytes(needle) { const hex = Buffer.from(needle).toString("hex"); - return [...this.records.values()].some((v) => Buffer.from(v).toString("hex").includes(hex)); + return [...this.records.values()].some((v) => + Buffer.from(v).toString("hex").includes(hex), + ); + } +} + +class FaultBackend extends MemoryBackend { + failBefore; + failAfter; + async write(fence, ops) { + if (this.failBefore?.(ops)) { + this.failBefore = undefined; + throw new Error("synthetic commit refused"); + } + await super.write(fence, ops); + if (this.failAfter?.(ops)) { + this.failAfter = undefined; + throw new Error("synthetic response lost"); + } } } async function webCryptoSealer() { - const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, [ - "encrypt", - "decrypt", - ]); + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); return { async seal(aad, plaintext) { const iv = crypto.getRandomValues(new Uint8Array(12)); const sealed = new Uint8Array( - await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, key, plaintext), + await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData: aad }, + key, + plaintext, + ), ); const out = new Uint8Array(12 + sealed.length); out.set(iv); @@ -125,10 +247,21 @@ async function webCryptoSealer() { }; } -async function device(scope, { backend, sealer, holder = "tab-a", now = 0 } = {}) { +async function device( + scope, + { backend, sealer, holder = "tab-a", now = 0 } = {}, +) { backend ??= new MemoryBackend(); sealer ??= await webCryptoSealer(); - const signal = await SignalDevice.open(backend, sealer, scope, holder, now, TTL, true); + const signal = await SignalDevice.open( + backend, + sealer, + scope, + holder, + now, + TTL, + true, + ); return { signal, backend, sealer }; } @@ -162,16 +295,33 @@ test("a session runs both ways and nothing readable reaches storage", async () = const first = await alice.signal.encrypt(BOB, 1, text("hello bob")); assert.equal(first.kind, "pkmsg"); - const opened = await bob.signal.decrypt(ALICE, 1, first.kind, first.ciphertext); + const opened = await bob.signal.decrypt( + ALICE, + 1, + first.kind, + first.ciphertext, + ); assert.equal(read(opened.plaintext), "hello bob"); const reply = await bob.signal.encrypt(ALICE, 1, text("hi alice")); assert.equal(reply.kind, "msg"); - assert.equal(read((await alice.signal.decrypt(BOB, 1, reply.kind, reply.ciphertext)).plaintext), "hi alice"); + assert.equal( + read( + (await alice.signal.decrypt(BOB, 1, reply.kind, reply.ciphertext)) + .plaintext, + ), + "hi alice", + ); for (let round = 0; round < 5; round++) { const out = await alice.signal.encrypt(BOB, 1, text(`a${round}`)); - assert.equal(read((await bob.signal.decrypt(ALICE, 1, out.kind, out.ciphertext)).plaintext), `a${round}`); + assert.equal( + read( + (await bob.signal.decrypt(ALICE, 1, out.kind, out.ciphertext)) + .plaintext, + ), + `a${round}`, + ); } assert.equal(bob.backend.containsBytes(text("hello bob")), false); @@ -183,7 +333,12 @@ test("a second tab is refused while the lease is live, then fences the first out const alice = await device("alice-failover"); const backend = new MemoryBackend(); const sealer = await webCryptoSealer(); - const bobA = await device("bob-failover", { backend, sealer, holder: "tab-a", now: 0 }); + const bobA = await device("bob-failover", { + backend, + sealer, + holder: "tab-a", + now: 0, + }); await alice.signal.establishSession(BOB, 1, await bundleFor(bobA.signal)); const first = await alice.signal.encrypt(BOB, 1, text("before")); await bobA.signal.decrypt(ALICE, 1, first.kind, first.ciphertext); @@ -193,14 +348,25 @@ test("a second tab is refused while the lease is live, then fences the first out (error) => error.code === "lease_held", ); - const bobB = await SignalDevice.open(backend, sealer, "bob-failover", "tab-b", TTL + 1, TTL, false); + const bobB = await SignalDevice.open( + backend, + sealer, + "bob-failover", + "tab-b", + TTL + 1, + TTL, + false, + ); const next = await alice.signal.encrypt(BOB, 1, text("after")); await assert.rejects( bobA.signal.decrypt(ALICE, 1, next.kind, next.ciphertext), (error) => error.code === "fence_lost", ); assert.equal(await bobA.signal.renewLease(TTL + 2, TTL), false); - assert.equal(read((await bobB.decrypt(ALICE, 1, next.kind, next.ciphertext)).plaintext), "after"); + assert.equal( + read((await bobB.decrypt(ALICE, 1, next.kind, next.ciphertext)).plaintext), + "after", + ); }); test("group messages decrypt with a distributed sender key", async () => { @@ -211,11 +377,29 @@ test("group messages decrypt with a distributed sender key", async () => { const skdm = await alice.signal.senderKeyDistribution(group, ALICE, 1); const carried = await alice.signal.encrypt(BOB, 1, skdm); - const received = await bob.signal.decrypt(ALICE, 1, carried.kind, carried.ciphertext); - await bob.signal.processSenderKeyDistribution(group, ALICE, 1, received.plaintext); + const received = await bob.signal.decrypt( + ALICE, + 1, + carried.kind, + carried.ciphertext, + ); + await bob.signal.processSenderKeyDistribution( + group, + ALICE, + 1, + received.plaintext, + ); - const skmsg = await alice.signal.groupEncrypt(group, ALICE, 1, text("hello group")); - assert.equal(read(await bob.signal.groupDecrypt(group, ALICE, 1, skmsg)), "hello group"); + const skmsg = await alice.signal.groupEncrypt( + group, + ALICE, + 1, + text("hello group"), + ); + assert.equal( + read(await bob.signal.groupDecrypt(group, ALICE, 1, skmsg)), + "hello group", + ); }); test("a sealer with the wrong key cannot open the device, and the lease is released", async () => { @@ -223,18 +407,35 @@ test("a sealer with the wrong key cannot open the device, and the lease is relea const first = await device("bob-key", { backend }); await first.signal.release(); await assert.rejects( - SignalDevice.open(backend, await webCryptoSealer(), "bob-key", "tab-b", 1, TTL, false), + SignalDevice.open( + backend, + await webCryptoSealer(), + "bob-key", + "tab-b", + 1, + TTL, + false, + ), (error) => error.code === "store", ); // The failed open gave its lease back, so another tab can take the scope. - await SignalDevice.open(backend, first.sealer, "bob-key", "tab-c", 2, TTL, false); + await SignalDevice.open( + backend, + first.sealer, + "bob-key", + "tab-c", + 2, + TTL, + false, + ); }); test("pairing and input errors carry codes", async () => { const bob = await device("bob-errors"); await assert.rejects( bob.signal.signPairing(text("not a container")), - (error) => error.code === "pairing_refused" && typeof error.status === "number", + (error) => + error.code === "pairing_refused" && typeof error.status === "number", ); await assert.rejects( bob.signal.decrypt(ALICE, 1, "skmsg", new Uint8Array(4)), @@ -268,7 +469,11 @@ test("receive buffers until delivered and recognises redelivery after", async () const alice = await device("alice-receive"); const bob = await device("bob-receive"); await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); - const sent = await alice.signal.encrypt(BOB, 1, paddedConversation("hello from js")); + const sent = await alice.signal.encrypt( + BOB, + 1, + paddedConversation("hello from js"), + ); const args = [ALICE, ALICE, 1, sent.kind, sent.ciphertext, 2, false]; const first = await bob.signal.receive(...args); @@ -305,7 +510,9 @@ test("sends are built in the page and only ask the resolver for devices and keys }, async fetchPrekeys(jids) { calls.prekeys.push(jids); - return Object.fromEntries(jids.filter((j) => j === bobDevice).map((j) => [j, bobBundle])); + return Object.fromEntries( + jids.filter((j) => j === bobDevice).map((j) => [j, bobBundle]), + ); }, async resolveGroup() { return { @@ -315,23 +522,50 @@ test("sends are built in the page and only ask the resolver for devices and keys }, }; - const first = await alice.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("hi"), "J1"); + const first = await alice.signal.sendDirect( + resolver, + "222@s.whatsapp.net", + conversation("hi"), + "J1", + ); assert.ok(first.stanza instanceof Uint8Array && first.stanza.length > 0); assert.deepEqual(first.unreachedDevices, []); - assert.deepEqual(calls.devices[0].sort(), ["111@s.whatsapp.net", "222@s.whatsapp.net"]); + assert.deepEqual(calls.devices[0].sort(), [ + "111@s.whatsapp.net", + "222@s.whatsapp.net", + ]); assert.deepEqual(calls.prekeys, [[bobDevice]]); - await alice.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("again"), "J2"); - assert.equal(calls.prekeys.length, 1, "an established session needs no new bundle"); + await alice.signal.sendDirect( + resolver, + "222@s.whatsapp.net", + conversation("again"), + "J2", + ); + assert.equal( + calls.prekeys.length, + 1, + "an established session needs no new bundle", + ); assert.equal(alice.backend.containsBytes(text("again")), false); - const group = await alice.signal.sendGroup(resolver, "120363000000001@g.us", conversation("g"), "G1"); + const group = await alice.signal.sendGroup( + resolver, + "120363000000001@g.us", + conversation("g"), + "G1", + ); assert.ok(group.stanza.length > 0); assert.deepEqual(group.distributionTargets, [bobDevice]); const unpaired = await device("unpaired-js"); await assert.rejects( - unpaired.signal.sendDirect(resolver, "222@s.whatsapp.net", conversation("x"), "J3"), + unpaired.signal.sendDirect( + resolver, + "222@s.whatsapp.net", + conversation("x"), + "J3", + ), (error) => error.code === "invalid_input", ); }); @@ -359,3 +593,545 @@ test("sessions follow a user from phone number to LID", async () => { assert.equal(await alice.signal.hasSession(BOB_LID, 1), true); assert.equal(await alice.signal.migratePnToLid(BOB, BOB_LID), 0); }); + +test("durable public uploads recover failed commits and lost prepare or ACK responses", async () => { + const backend = new FaultBackend(); + const bob = await device("bob-upload-durable", { backend }); + backend.failBefore = (ops) => ops.some((op) => op.ns === "prekey"); + await assert.rejects( + bob.signal.preparePrekeyUpload(3), + (error) => error.code === "store", + ); + assert.equal( + [...backend.records.keys()].filter((key) => key.includes("\0prekey\0")) + .length, + 0, + ); + backend.failAfter = (ops) => ops.some((op) => op.key === "prekey-upload-v1"); + await assert.rejects( + bob.signal.preparePrekeyUpload(3), + (error) => error.code === "store", + ); + const batch = await bob.signal.preparePrekeyUpload(3); + assert.deepEqual(Object.keys(batch).sort(), ["batchId", "preKeys"]); + assert.match(batch.batchId, /^[0-9a-f]{32}$/); + for (const key of batch.preKeys) { + assert.deepEqual(Object.keys(key).sort(), ["id", "publicKey"]); + assert.equal(key.publicKey.length, 32); + } + for (let i = 0; i < 12; i++) + assert.deepEqual(await bob.signal.preparePrekeyUpload(812), batch); + const fresh = await bob.signal.generatePrekeys(1); + assert.equal(fresh[0].id, batch.preKeys.at(-1).id + 1); + assert.equal(await bob.signal.uploadedPrekeyCount(), 0); + const ids = Uint32Array.from(batch.preKeys.map((key) => key.id)); + await assert.rejects( + bob.signal.acknowledgePrekeyUpload(batch.batchId, ids.subarray(0, 1)), + (e) => e.code === "invalid_input", + ); + await assert.rejects( + bob.signal.acknowledgePrekeyUpload("foreign", ids), + (e) => e.code === "invalid_input", + ); + backend.failAfter = (ops) => + ops.some((op) => op.key === "prekey-upload-ack-v1"); + await assert.rejects( + bob.signal.acknowledgePrekeyUpload(batch.batchId, ids), + (e) => e.code === "store", + ); + await bob.signal.release(); + bob.signal.free(); + const reopened = await SignalDevice.open( + backend, + bob.sealer, + "bob-upload-durable", + "tab-b", + 1, + TTL, + false, + ); + await reopened.acknowledgePrekeyUpload(batch.batchId, ids); + assert.equal(await reopened.uploadedPrekeyCount(), 3); + const next = await reopened.preparePrekeyUpload(1); + await reopened.acknowledgePrekeyUpload(batch.batchId, ids); + assert.deepEqual(await reopened.preparePrekeyUpload(1), next); + assert.equal( + await reopened.uploadedPrekeyCount(), + 3, + "fresh retry key was not marked uploaded", + ); + await reopened.release(); + reopened.free(); +}); + +test("uploaded counts and recovered batches exclude keys consumed by actual encrypted receive", async () => { + const alice = await device("alice-upload-consumption"); + const bob = await device("bob-upload-consumption"); + const batch = await bob.signal.preparePrekeyUpload(2); + const bundle = await bundleFor(bob.signal, false); + bundle.prekeyId = batch.preKeys[0].id; + bundle.prekey = batch.preKeys[0].publicKey; + await alice.signal.establishSession(BOB, 1, bundle); + const cipher = await alice.signal.encrypt( + BOB, + 1, + paddedConversation("durable upload message"), + ); + const result = await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + false, + ); + assert.equal(conversationOf(result.message), "durable upload message"); + assert.equal(result.senderKeyRejected, false); + assert.equal(result.appStateKeyShare.length, 0); + const retry = await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + false, + ); + assert.equal(retry.redelivered, true); + assert.equal(retry.receiptKey, result.receiptKey); + await persistThenMark(bob.signal, result); + assert.equal( + ( + await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + false, + ) + ).status, + "already_delivered", + ); + const live = await bob.signal.preparePrekeyUpload(1); + assert.equal(live.batchId, batch.batchId); + assert.deepEqual(live.preKeys, batch.preKeys.slice(1)); + await bob.signal.acknowledgePrekeyUpload( + batch.batchId, + Uint32Array.from(batch.preKeys.map((key) => key.id)), + ); + assert.equal(await bob.signal.uploadedPrekeyCount(), 1); + const other = await alice.signal.encrypt( + BOB, + 1, + paddedConversation("not another prekey"), + ); + await bob.signal.receive( + ALICE, + ALICE, + 1, + other.kind, + other.ciphertext, + 2, + false, + ); + assert.equal(await bob.signal.uploadedPrekeyCount(), 1); + await bob.signal.release(); + bob.signal.free(); + const reopened = await SignalDevice.open( + bob.backend, + bob.sealer, + "bob-upload-consumption", + "tab-b", + 1, + TTL, + false, + ); + await reopened.acknowledgePrekeyUpload( + batch.batchId, + Uint32Array.from(batch.preKeys.map((key) => key.id)), + ); + assert.equal(await reopened.uploadedPrekeyCount(), 1); + await reopened.release(); + reopened.free(); +}); + +test("uploaded counts and durable upload acknowledgments reject stale holders", async () => { + const bob = await device("bob-upload-fenced"); + const batch = await bob.signal.preparePrekeyUpload(2); + const next = await SignalDevice.open( + bob.backend, + bob.sealer, + "bob-upload-fenced", + "tab-b", + TTL + 1, + TTL, + false, + ); + for (const operation of [ + () => bob.signal.uploadedPrekeyCount(), + () => bob.signal.preparePrekeyUpload(2), + () => + bob.signal.acknowledgePrekeyUpload( + batch.batchId, + Uint32Array.from(batch.preKeys.map((key) => key.id)), + ), + ]) + await assert.rejects(operation(), (e) => e.code === "fence_lost"); + assert.deepEqual(await next.preparePrekeyUpload(1), batch); + await next.release(); + next.free(); +}); + +test("actual WASM retry stanzas decrypt at the peer, including fresh keys after a reset", async () => { + const alice = await device("alice-resend"); + const bob = await device("bob-resend"); + await alice.signal.setOwnJids("111:1@s.whatsapp.net", undefined); + await pair(alice.signal); + const resolver = resolverFor(await bundleFor(bob.signal)); + await alice.signal.sendDirect( + resolver, + BOB, + conversation("lost once"), + "RETRY1", + ); + const request = { + chat: BOB, + messageId: "RETRY1", + requester: "222:1@s.whatsapp.net", + encryptionJid: "222:1@s.whatsapp.net", + retryCount: 1, + route: { kind: "direct" }, + }; + const first = await alice.signal.resend(resolver, request); + const tree = stanza(first); + assert.equal(tree.attrs.id, "RETRY1"); + assert.equal(find(tree, "enc").attrs.count, "1"); + const delivered = await receiveStanza(bob.signal, first); + assert.equal(conversationOf(delivered.message), "lost once"); + await persistThenMark(bob.signal, delivered); + const reset = await device("bob-resend-after-reset"); + const freshBundle = await bundleFor(reset.signal); + const second = await alice.signal.resend(resolver, { + ...request, + retryCount: 2, + bundle: freshBundle, + }); + assert.equal(find(stanza(second), "enc").attrs.count, "2"); + const recovered = await receiveStanza(reset.signal, second); + assert.equal(conversationOf(recovered.message), "lost once"); + await persistThenMark(reset.signal, recovered); + await assert.rejects( + alice.signal.resend(resolver, { ...request, messageId: "missing" }), + (e) => e.code === "invalid_input", + ); + assert.equal(alice.backend.containsBytes(text("lost once")), false); + assert.equal( + await alice.signal.pruneSentMessages(Number.MAX_SAFE_INTEGER), + 1, + ); + await assert.rejects( + alice.signal.resend(resolver, request), + (e) => e.code === "invalid_input", + ); +}); + +test("a group retry clears only the requester distribution acknowledgment", async () => { + const group = "120363000000001@g.us"; + const alice = await device("alice-group-resend"); + const bob = await device("bob-group-resend"); + await alice.signal.setOwnJids("111:1@s.whatsapp.net", "900111:1@lid"); + await pair(alice.signal); + const primary = await device("bob-primary-group-resend"); + const primaryBundle = { ...(await bundleFor(primary.signal)), deviceId: 0 }; + const companionBundle = await bundleFor(bob.signal); + const resolver = resolverFor(companionBundle, group); + resolver.resolveDevices = async () => [BOB, "222:1@s.whatsapp.net"]; + resolver.fetchPrekeys = async (jids) => + Object.fromEntries( + jids.map((jid) => [jid, jid === BOB ? primaryBundle : companionBundle]), + ); + const initial = await alice.signal.sendGroup( + resolver, + group, + conversation("group retry"), + "GROUP1", + ); + assert.deepEqual( + [...initial.distributionTargets].sort(), + [BOB, "222:1@s.whatsapp.net"].sort(), + ); + await alice.signal.markSenderKeyDistributed( + group, + initial.distributionTargets, + ); + assert.deepEqual( + ( + await alice.signal.sendGroup( + resolver, + group, + conversation("warm"), + "GROUP2", + ) + ).distributionTargets, + [], + ); + const retried = await alice.signal.resend(resolver, { + chat: group, + messageId: "GROUP1", + requester: "222:1@s.whatsapp.net", + encryptionJid: "222:1@s.whatsapp.net", + retryCount: 1, + route: { kind: "group", addressingMode: "pn" }, + }); + assert.equal(stanza(retried).attrs.to, group); + const received = await receiveStanza(bob.signal, retried, group); + assert.equal(conversationOf(received.message), "group retry"); + await persistThenMark(bob.signal, received); + assert.deepEqual( + ( + await alice.signal.sendGroup( + resolver, + group, + conversation("redistribute"), + "GROUP3", + ) + ).distributionTargets, + ["222:1@s.whatsapp.net"], + ); +}); + +// Synthetic protobuf fixtures follow waproto/src/whatsapp.proto field numbers. +function varint(value) { + const bytes = []; + do { + bytes.push((value & 127) | (value > 127 ? 128 : 0)); + value = Math.floor(value / 128); + } while (value); + return Uint8Array.from(bytes); +} +function field(number, bytes) { + return Uint8Array.from([ + ...varint(number * 8 + 2), + ...varint(bytes.length), + ...bytes, + ]); +} +function pad(bytes) { + const count = 1 + (bytes.length % 16); + return Uint8Array.from([...bytes, ...new Array(count).fill(count)]); +} + +test("receive exposes rejected sender keys without losing ordinary application content", async () => { + const alice = await device("alice-rejected-skdm"); + const bob = await device("bob-rejected-skdm"); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + const badSkdm = field(2, [ + ...field(1, text("120363000000001@g.us")), + ...field(2, [1, 2, 3]), + ]); + const bytes = pad( + Uint8Array.from([...conversation("keep the text"), ...badSkdm]), + ); + const cipher = await alice.signal.encrypt(BOB, 1, bytes); + const received = await bob.signal.receive( + "120363000000001@g.us", + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + false, + ); + assert.equal(received.senderKeyRejected, true); + assert.equal(conversationOf(received.message), "keep the text"); + assert.equal( + Buffer.from(received.message).includes(Buffer.from(badSkdm)), + false, + ); + await persistThenMark(bob.signal, received); +}); + +test("app-state sharing requires separate explicit consent and own-device evidence", async () => { + const share = field(1, [ + ...field(1, field(1, new Uint8Array(32).fill(3))), + ...field(2, field(1, new Uint8Array(32).fill(4))), + ]); + const protocol = field(12, [...varint(2 * 8), 6, ...field(7, share)]); + for (const [consent, fromMe] of [ + [undefined, true], + [false, true], + [true, true], + [true, false], + ]) { + const alice = await device(`alice-share-${consent}-${fromMe}`); + const bob = await device(`bob-share-${consent}-${fromMe}`); + await alice.signal.establishSession(BOB, 1, await bundleFor(bob.signal)); + const cipher = await alice.signal.encrypt( + BOB, + 1, + pad( + Uint8Array.from([ + ...conversation("mixed own-device content"), + ...protocol, + ]), + ), + ); + const received = await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + fromMe, + consent, + ); + assert.equal(conversationOf(received.message), "mixed own-device content"); + assert.equal( + Buffer.from(received.message).includes(Buffer.from(share)), + false, + "application bytes must omit key material", + ); + assert.equal( + received.appStateKeyShare.length !== 0, + consent === true && fromMe, + ); + if (consent === true && fromMe) + assert.ok( + Buffer.from(received.appStateKeyShare).equals(Buffer.from(share)), + "separate share bytes match the synthetic protocol fixture", + ); + const retry = await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + fromMe, + ); + assert.equal(retry.redelivered, true); + assert.equal(retry.appStateKeyShare.length, 0); + await persistThenMark(bob.signal, received); + assert.equal( + ( + await bob.signal.receive( + ALICE, + ALICE, + 1, + cipher.kind, + cipher.ciphertext, + 2, + fromMe, + true, + ) + ).status, + "already_delivered", + ); + } +}); + +test("new WASM input validation refuses malformed counts, routes and retry metadata", async () => { + const bob = await device("bob-new-inputs"); + for (const count of [0, -1, 1.5, 813, NaN, Infinity, 2 ** 32 + 1]) + await assert.rejects( + bob.signal.preparePrekeyUpload(count), + (e) => e.code === "invalid_input", + ); + for (const value of [-1, 0.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) + await assert.rejects( + bob.signal.pruneSentMessages(value), + (e) => e.code === "invalid_input", + ); + for (const value of ["false", "true", 1, {}]) + await assert.rejects( + bob.signal.receive( + ALICE, + ALICE, + 1, + "pkmsg", + new Uint8Array(), + 2, + true, + value, + ), + (e) => e.code === "invalid_input", + ); + const request = { + chat: BOB, + messageId: "id", + requester: "222:1@s.whatsapp.net", + encryptionJid: "222:1@s.whatsapp.net", + retryCount: 1, + route: { kind: "direct" }, + }; + const resolver = resolverFor(await bundleFor(bob.signal)); + for (const patch of [ + { retryCount: 256 }, + { retryCount: 0.5 }, + { route: { kind: "unknown" } }, + { route: { kind: "group", addressingMode: "unknown" } }, + { bundle: { registrationId: 1 } }, + { requester: "invalid jid" }, + ]) + await assert.rejects( + bob.signal.resend(resolver, { ...request, ...patch }), + (e) => e.code === "invalid_input", + ); +}); + +test("initialized-module upload example keeps pairing secrets out of its callback", async () => { + const backend = new MemoryBackend(); + const sealer = await webCryptoSealer(); + const options = { + backend, + sealer, + scope: "example-upload", + holder: "example-a", + nowMs: 0, + ttlMs: TTL, + count: 2, + }; + let first; + await assert.rejects( + uploadPublicPrekeys({ SignalDevice }, options, async (upload) => { + first = upload; + throw new Error("synthetic provider response lost"); + }), + ); + assert.deepEqual(Object.keys(first).sort(), [ + "batchId", + "identityKey", + "preKeys", + "registrationId", + "signedPrekey", + ]); + const id = await uploadPublicPrekeys( + { SignalDevice }, + { ...options, holder: "example-b", nowMs: 1 }, + async (upload) => assert.deepEqual(upload, first), + ); + assert.equal(id, first.batchId); + const reopened = await SignalDevice.open( + backend, + sealer, + "example-upload", + "example-c", + 2, + TTL, + false, + ); + assert.equal(await reopened.uploadedPrekeyCount(), 2); + await reopened.release(); + reopened.free(); + const raw = new WebAssembly.Instance( + new WebAssembly.Module(Uint8Array.from([0, 97, 115, 109, 1, 0, 0, 0])), + ); + await assert.rejects( + uploadPublicPrekeys(raw, options, async () => {}), + TypeError, + ); +}); diff --git a/wacore/signal-wasm/tests/types.ts b/wacore/signal-wasm/tests/types.ts new file mode 100644 index 00000000..10401e74 --- /dev/null +++ b/wacore/signal-wasm/tests/types.ts @@ -0,0 +1,71 @@ +// Compile against the actual generated bindings after the Node-target build. +import { + SignalDevice, + type PrekeyUploadBatch, + type ReceiveResult, + type SendResolver, +} from "../../../target/signal-wasm-node/wacore_signal_wasm.js"; + +declare const device: SignalDevice; +declare const resolver: SendResolver; +const bytes = new Uint8Array(); +const batch: PrekeyUploadBatch = await device.preparePrekeyUpload(2); +await device.acknowledgePrekeyUpload( + batch.batchId, + Uint32Array.from(batch.preKeys.map((key) => key.id)), +); +const count: number = await device.uploadedPrekeyCount(); +const received: ReceiveResult = await device.receive( + "chat", + "sender", + 1, + "pkmsg", + bytes, + 2, + false, +); +await device.receive("chat", "sender", 1, "pkmsg", bytes, 2, true, true); +if (received.status === "message") { + const rejected: boolean = received.senderKeyRejected; + const share: Uint8Array = received.appStateKeyShare; + void rejected; + void share; +} +const request = { + chat: "chat", + messageId: "id", + requester: "peer", + encryptionJid: "peer", + retryCount: 1, +}; +await device.resend(resolver, { ...request, route: { kind: "direct" } }); +await device.resend(resolver, { + ...request, + route: { kind: "group", addressingMode: "lid" }, +}); +await device.resend(resolver, { + ...request, + route: { kind: "broadcast_list" }, +}); +// @ts-expect-error Consent is an explicit boolean, never a truthy string. +await device.receive("chat", "sender", 1, "pkmsg", bytes, 2, true, "true"); +// @ts-expect-error Group retries require a declared addressing mode. +await device.resend(resolver, { ...request, route: { kind: "group" } }); +await device.resend(resolver, { + ...request, + // @ts-expect-error Retry counts remain numeric public metadata. + retryCount: "1", + route: { kind: "direct" }, +}); +const invalid = + // @ts-expect-error Only a message result contains application bytes. + received.status === "already_delivered" ? received.message : undefined; +declare function acceptModule(module: { + SignalDevice: typeof SignalDevice; +}): void; +acceptModule({ SignalDevice }); +declare const raw: WebAssembly.Instance; +// @ts-expect-error A raw WebAssembly instance lacks wasm-bindgen's class/glue. +acceptModule(raw); +void count; +void invalid; From 4b3b13cacd40a27573d0246386db553ef887539b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 15 Oct 2026 07:52:25 +0300 Subject: [PATCH 17/17] fix(signal): protect pending batches from legacy prekey marks --- wacore/signal-ops/src/lib.rs | 4 +- wacore/signal-ops/src/ops.rs | 6 +- wacore/signal-ops/src/prekeys.rs | 15 ++++ wacore/signal-ops/tests/prekey_upload.rs | 106 ++++++++++++++++++++++- wacore/signal-wasm/src/lib.rs | 2 + wacore/signal-wasm/tests/node.test.mjs | 11 +++ 6 files changed, 140 insertions(+), 4 deletions(-) diff --git a/wacore/signal-ops/src/lib.rs b/wacore/signal-ops/src/lib.rs index 45a9905d..8a37d9bb 100644 --- a/wacore/signal-ops/src/lib.rs +++ b/wacore/signal-ops/src/lib.rs @@ -16,7 +16,9 @@ //! more private keys. Acknowledge a successful server upload with //! [`SignalOps::acknowledge_prekey_upload`]; the last exact acknowledgment is //! replayable after a lost response. [`SignalOps::generate_prekeys`] remains -//! fresh generation for retry receipts. All three use the scope's fenced store; +//! fresh generation for retry receipts; [`SignalOps::mark_prekeys_uploaded`] +//! marks only those fresh keys and rejects pending upload members atomically. +//! These operations use the scope's fenced store; //! upload batch IDs remain reserved across 24-bit wrap even after consumption. mod device; diff --git a/wacore/signal-ops/src/ops.rs b/wacore/signal-ops/src/ops.rs index dda87678..6e7fcce9 100644 --- a/wacore/signal-ops/src/ops.rs +++ b/wacore/signal-ops/src/ops.rs @@ -352,9 +352,11 @@ impl SignalOps { self.generate_fresh_prekeys(count).await } - /// Record that the client uploaded these prekeys. Consumed keys stay gone. + /// Mark fresh retry prekeys uploaded. Any original member of the pending + /// upload batch rejects the entire call; use `acknowledge_prekey_upload` + /// for that batch. Consumed keys stay gone. pub async fn mark_prekeys_uploaded(&self, ids: &[u32]) -> Result<(), OpsError> { - Ok(self.store.mark_prekeys_uploaded(ids).await?) + self.mark_fresh_prekeys_uploaded(ids).await } /// Replace the signed prekey. The old one stays loadable by ID so prekey diff --git a/wacore/signal-ops/src/prekeys.rs b/wacore/signal-ops/src/prekeys.rs index acf8ef8d..7e5a90d8 100644 --- a/wacore/signal-ops/src/prekeys.rs +++ b/wacore/signal-ops/src/prekeys.rs @@ -242,6 +242,21 @@ impl SignalOps { self.allocate_prekeys(count, &reserved, None).await } + pub(super) async fn mark_fresh_prekeys_uploaded(&self, ids: &[u32]) -> Result<(), OpsError> { + let _held = self.device_write.lock().await; + if let Some(batch) = self.pending_upload().await? { + let reserved: BTreeSet<_> = batch.pre_keys.iter().map(|key| key.id).collect(); + // Original membership, including consumed keys, remains batch + // authority until ACK or retirement. Check before any flag write. + if ids.iter().any(|id| reserved.contains(id)) { + return Err(OpsError::InvalidInput( + "pending upload members require batch acknowledgment".into(), + )); + } + } + Ok(self.store.mark_prekeys_uploaded(ids).await?) + } + async fn allocate_prekeys( &self, count: u32, diff --git a/wacore/signal-ops/tests/prekey_upload.rs b/wacore/signal-ops/tests/prekey_upload.rs index d385c7bc..51abe0cc 100644 --- a/wacore/signal-ops/tests/prekey_upload.rs +++ b/wacore/signal-ops/tests/prekey_upload.rs @@ -1,5 +1,6 @@ //! Public upload retries recover from the sealed store, not process memory. use async_trait::async_trait; +use futures::channel::oneshot; use futures::executor::block_on; use std::sync::{ Arc, Mutex, @@ -18,6 +19,7 @@ use wacore_signal_ops::{DeviceKeys, SignalOps}; const SCOPE: &str = "synthetic-upload-device"; const PENDING: &str = "prekey-upload-v1"; const MAX: u32 = 0x00ff_ffff; +type ReadGate = (oneshot::Sender<()>, oneshot::Receiver<()>); #[derive(Default)] struct Records { @@ -26,12 +28,23 @@ struct Records { consume: AtomicU32, batches: Mutex>>, reads: AtomicUsize, + pending_read_gate: Mutex>, } #[async_trait] impl RecordStore for Records { async fn get(&self, scope: &str, ns: Namespace, key: &str) -> StoreResult>> { self.reads.fetch_add(1, Ordering::SeqCst); - self.inner.get(scope, ns, key).await + let value = self.inner.get(scope, ns, key).await?; + let gate = if ns == Namespace::Device && key == PENDING { + self.pending_read_gate.lock().unwrap().take() + } else { + None + }; + if let Some((entered, resume)) = gate { + entered.send(()).unwrap(); + resume.await.unwrap(); + } + Ok(value) } async fn scan_keys( &self, @@ -199,6 +212,97 @@ impl Fixture { } } +#[test] +fn legacy_mark_is_fresh_only_and_rejects_all_original_pending_members_atomically() { + block_on(async { + let f = Fixture::new().await; + let batch = f.ops.prepare_prekey_upload(2).await.unwrap(); + let fresh = f.ops.generate_prekeys(1).await.unwrap()[0].id; + for ids in [vec![1], vec![fresh, 2]] { + let writes = f.records.batches.lock().unwrap().len(); + assert!(matches!( + f.ops.mark_prekeys_uploaded(&ids).await, + Err(wacore_signal_ops::OpsError::InvalidInput(_)) + )); + assert_eq!(f.records.batches.lock().unwrap().len(), writes); + assert_eq!(f.uploaded(fresh).await, Some(false)); + assert_eq!(f.uploaded(2).await, Some(false)); + } + f.store.remove_prekey(1).await.unwrap(); + let recovered = f.ops.prepare_prekey_upload(1).await.unwrap(); + assert_eq!(recovered.pre_keys, batch.pre_keys[1..]); + assert!(f.ops.mark_prekeys_uploaded(&[fresh, 1]).await.is_err()); + assert_eq!(f.uploaded(1).await, None); + assert_eq!(f.uploaded(fresh).await, Some(false)); + f.ops.mark_prekeys_uploaded(&[fresh]).await.unwrap(); + assert_eq!(f.uploaded(fresh).await, Some(true)); + assert_eq!(f.ops.prepare_prekey_upload(1).await.unwrap(), recovered); + f.ops + .acknowledge_prekey_upload(&batch.batch_id, &[1, 2]) + .await + .unwrap(); + assert_eq!(f.uploaded(1).await, None); + assert_eq!(f.uploaded(2).await, Some(true)); + }); +} + +#[test] +fn legacy_mark_refuses_corrupt_pending_metadata_and_stale_fences() { + block_on(async { + let f = Fixture::new().await; + let fresh = f.ops.generate_prekeys(1).await.unwrap()[0].id; + f.store + .put_aux(Namespace::Device, PENDING, b"invalid-batch") + .await + .unwrap(); + assert!(f.ops.mark_prekeys_uploaded(&[fresh]).await.is_err()); + assert_eq!(f.uploaded(fresh).await, Some(false)); + f.store + .delete_aux(Namespace::Device, &[PENDING]) + .await + .unwrap(); + f.records + .inner + .acquire(SCOPE, "holder-b", 60_001, 60_000) + .await + .unwrap() + .unwrap(); + assert!( + f.ops + .mark_prekeys_uploaded(&[fresh]) + .await + .unwrap_err() + .is_fence_lost() + ); + assert_eq!(f.uploaded(fresh).await, Some(false)); + }); +} + +#[test] +fn legacy_mark_and_prepare_share_the_device_mutex() { + block_on(async { + let f = Fixture::new().await; + let (entered, observed) = oneshot::channel(); + let (resume, wait) = oneshot::channel(); + *f.records.pending_read_gate.lock().unwrap() = Some((entered, wait)); + // The mark reads no pending batch, then pauses before its flag write. + let mark = f.ops.mark_prekeys_uploaded(&[1]); + futures::pin_mut!(mark); + assert!(futures::poll!(&mut mark).is_pending()); + observed.await.unwrap(); + let prepare = f.ops.prepare_prekey_upload(1); + futures::pin_mut!(prepare); + assert!(futures::poll!(&mut prepare).is_pending()); + assert!(f.store.load_prekey(1).await.unwrap().is_none()); + resume.send(()).unwrap(); + let (marked, batch) = futures::join!(mark, prepare); + marked.unwrap(); + assert_eq!(batch.unwrap().pre_keys[0].id, 1); + // Prepare could not allocate between the guard read and legacy mark. + assert_eq!(f.uploaded(1).await, Some(false)); + }); +} + #[test] fn reopen_reuses_the_batch_and_repeated_requests_do_not_allocate_more() { block_on(async { diff --git a/wacore/signal-wasm/src/lib.rs b/wacore/signal-wasm/src/lib.rs index 4e79f20d..50710e04 100644 --- a/wacore/signal-wasm/src/lib.rs +++ b/wacore/signal-wasm/src/lib.rs @@ -273,6 +273,8 @@ impl SignalDevice { }) } + /// Mark fresh retry keys only. Any original pending upload member rejects + /// the entire call; use `acknowledgePrekeyUpload` for durable batches. #[wasm_bindgen(js_name = markPrekeysUploaded)] pub fn mark_prekeys_uploaded(&self, ids: Uint32Array) -> Promise { let inner = self.inner.clone(); diff --git a/wacore/signal-wasm/tests/node.test.mjs b/wacore/signal-wasm/tests/node.test.mjs index dbc0318c..a4acd076 100644 --- a/wacore/signal-wasm/tests/node.test.mjs +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -625,6 +625,11 @@ test("durable public uploads recover failed commits and lost prepare or ACK resp assert.equal(fresh[0].id, batch.preKeys.at(-1).id + 1); assert.equal(await bob.signal.uploadedPrekeyCount(), 0); const ids = Uint32Array.from(batch.preKeys.map((key) => key.id)); + await assert.rejects( + bob.signal.markPrekeysUploaded(Uint32Array.of(fresh[0].id, ids[0])), + (e) => e.code === "invalid_input", + ); + assert.equal(await bob.signal.uploadedPrekeyCount(), 0); await assert.rejects( bob.signal.acknowledgePrekeyUpload(batch.batchId, ids.subarray(0, 1)), (e) => e.code === "invalid_input", @@ -660,6 +665,8 @@ test("durable public uploads recover failed commits and lost prepare or ACK resp 3, "fresh retry key was not marked uploaded", ); + await reopened.markPrekeysUploaded(Uint32Array.of(fresh[0].id)); + assert.equal(await reopened.uploadedPrekeyCount(), 4); await reopened.release(); reopened.free(); }); @@ -718,6 +725,10 @@ test("uploaded counts and recovered batches exclude keys consumed by actual encr const live = await bob.signal.preparePrekeyUpload(1); assert.equal(live.batchId, batch.batchId); assert.deepEqual(live.preKeys, batch.preKeys.slice(1)); + await assert.rejects( + bob.signal.markPrekeysUploaded(Uint32Array.of(batch.preKeys[0].id)), + (e) => e.code === "invalid_input", + ); await bob.signal.acknowledgePrekeyUpload( batch.batchId, Uint32Array.from(batch.preKeys.map((key) => key.id)),