diff --git a/.github/workflows/wasm.yml b/.github/workflows/wasm.yml index fc863ee1..f11ccecc 100644 --- a/.github/workflows/wasm.yml +++ b/.github/workflows/wasm.yml @@ -86,6 +86,46 @@ 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: 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 + # 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: 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 6e914988..315a4522 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5323,6 +5323,64 @@ 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 = "wacore-signal-ops" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-lock", + "async-trait", + "buffa", + "bytes", + "futures", + "hex", + "hmac 0.13.0", + "rand 0.10.3", + "sha2 0.11.0", + "thiserror 2.0.21", + "tokio", + "wacore", + "wacore-binary", + "wacore-recordstore", + "waproto", +] + +[[package]] +name = "wacore-signal-wasm" +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", + "wacore-signal-ops", + "waproto", + "wasm-bindgen", + "wasm-bindgen-futures", +] + [[package]] name = "wait-timeout" version = "0.2.1" @@ -5394,6 +5452,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 ffc58df3..fa3cc06c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,9 @@ members = [ "wacore/derive", "wacore/libsignal", "wacore/noise", + "wacore/recordstore", + "wacore/signal-ops", + "wacore/signal-wasm", "waproto", ] default-members = [ @@ -55,6 +58,8 @@ default-members = [ "wacore/derive", "wacore/libsignal", "wacore/noise", + "wacore/recordstore", + "wacore/signal-ops", "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..c042b6b7 --- /dev/null +++ b/wacore/recordstore/src/conformance.rs @@ -0,0 +1,429 @@ +//! 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, is_record_exists, +}; +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; + 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; +} + +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" + ); +} + +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 new file mode 100644 index 00000000..aa53e92e --- /dev/null +++ b/wacore/recordstore/src/lib.rs @@ -0,0 +1,36 @@ +//! 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, 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 new file mode 100644 index 00000000..3383a4a0 --- /dev/null +++ b/wacore/recordstore/src/memory.rs @@ -0,0 +1,206 @@ +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}; +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 { + 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::Insert { 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..d0591e1c --- /dev/null +++ b/wacore/recordstore/src/record.rs @@ -0,0 +1,210 @@ +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, + /// 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, + /// Group member devices confirmed to hold this device's sender key. + SenderKeyDevices, +} + +impl Namespace { + pub const ALL: [Namespace; 9] = [ + Namespace::Identity, + Namespace::Session, + Namespace::PreKey, + Namespace::SignedPreKey, + Namespace::SenderKey, + Namespace::Device, + Namespace::DecryptBuffer, + Namespace::SentMessage, + Namespace::SenderKeyDevices, + ]; + + /// 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", + Namespace::Device => "device", + Namespace::DecryptBuffer => "decrypt_buffer", + Namespace::SentMessage => "sent_message", + Namespace::SenderKeyDevices => "sender_key_devices", + } + } +} + +/// 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 }, + /// 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. +#[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)) + } +} + +/// 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 { + 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. An `Insert` whose key + /// exists fails the batch with [`RecordExists::into_store_error`]. + 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..16e836b2 --- /dev/null +++ b/wacore/recordstore/src/signal.rs @@ -0,0 +1,1005 @@ +use crate::record::{Fence, Namespace, RecordStore, WriteOp}; +use crate::seal::{SealError, Sealer}; +use async_trait::async_trait; +use bytes::Bytes; +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex, MutexGuard}; +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; +const PREKEY_UPLOADED: u8 = 1; + +/// Namespaces the Signal service reads and writes directly, outside the +/// [`SignalStore`] surface. +const AUX_NAMESPACES: [Namespace; 4] = [ + Namespace::Device, + Namespace::DecryptBuffer, + Namespace::SentMessage, + Namespace::SenderKeyDevices, +]; + +/// A write before sealing. +#[derive(Debug, Clone)] +enum PlainOp { + Put(Namespace, String, Vec), + Insert(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 is still absent when the batch commits. + Insert(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 { + pub fn new(records: Arc, sealer: Arc, fence: Fence) -> Self { + Self { + records, + sealer, + fence, + overlay: None, + } + } + + pub fn fence(&self) -> &Fence { + &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 { + 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::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, + 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 + } + + /// 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 mut aad = + 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 + } + + 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) + } + + /// 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) | Staged::Insert(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), + } + } + + async fn load_many(&self, ns: Namespace, keys: &[&str]) -> Result)>> { + 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) | Staged::Insert(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) + } + + /// 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(_) | Staged::Insert(_) => { + 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 apply(&self, ops: Vec) -> Result<()> { + if ops.is_empty() { + return Ok(()); + } + 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::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), + }; + ((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::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, + 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<()> { + self.apply(vec![PlainOp::Put(ns, key.to_owned(), plaintext.to_vec())]) + .await + } + + async fn delete_keys>(&self, ns: Namespace, keys: &[K]) -> Result<()> { + 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 + } + + /// 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)?; + 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 + } + + /// 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 + } + + /// 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 + } + + async fn any_key_with_prefix(&self, ns: Namespace, prefix: &str) -> Result { + 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)) +} + +/// 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<()> { + 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> { + 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<()> { + 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)>> { + 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 { + 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 { + // 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<()> { + 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<()> { + 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> { + 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)?; + // Update, not Put: a key consumed after the read above stays + // deleted instead of being resurrected by this write. + ops.push(PlainOp::Update( + Namespace::PreKey, + key, + prekey_plaintext(&record, true), + )); + } + self.apply(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.scan(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.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? { + 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<()> { + 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>> { + 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 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 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 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(); + 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.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(), + 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); + } +} diff --git a/wacore/signal-ops/Cargo.toml b/wacore/signal-ops/Cargo.toml new file mode 100644 index 00000000..6da1cab0 --- /dev/null +++ b/wacore/signal-ops/Cargo.toml @@ -0,0 +1,33 @@ +[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] +anyhow = { workspace = true } +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-binary = { workspace = true } +wacore-recordstore = { path = "../recordstore" } +waproto = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] } +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..ed85e943 --- /dev/null +++ b/wacore/signal-ops/src/device.rs @@ -0,0 +1,264 @@ +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, + /// 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. +#[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 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; + +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, + account: None, + own_pn: None, + own_lid: None, + }) + } + + 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 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(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); + 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()); + 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}")); + 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| -> Result<&[u8], OpsError> { + let slice = bytes + .get(at..at + len) + .ok_or_else(|| invalid("truncated"))?; + at += len; + Ok(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)?); + 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, + adv_secret, + signed_prekey_id, + signed_prekey, + signed_prekey_signature, + next_prekey_id, + account, + own_pn, + own_lid, + }) + } +} + +/// 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 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(); + 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..e39462fc --- /dev/null +++ b/wacore/signal-ops/src/error.rs @@ -0,0 +1,38 @@ +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 }, + /// 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, +} + +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/inbound.rs b/wacore/signal-ops/src/inbound.rs new file mode 100644 index 00000000..c6705c95 --- /dev/null +++ b/wacore/signal-ops/src/inbound.rs @@ -0,0 +1,346 @@ +//! 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, chain_key, session_key}; +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, + /// 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. + 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 { + 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( + 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 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() + .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, + sender_key_rejected: false, + }))); + } + + 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); + } + }; + + // 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() + { + sender_key_rejected = true; + } + + 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, + sender_key_rejected, + }))) + } + + /// 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 new file mode 100644 index 00000000..8a37d9bb --- /dev/null +++ b/wacore/signal-ops/src/lib.rs @@ -0,0 +1,38 @@ +//! 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. +//! +//! 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; [`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; +mod error; +mod inbound; +mod ops; +mod outbound; +mod stores; + +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, 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 new file mode 100644 index 00000000..6e7fcce9 --- /dev/null +++ b/wacore/signal-ops/src/ops.rs @@ -0,0 +1,742 @@ +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::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. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +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. + 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 + /// ([`Namespace::Identity`]) starting with `prefix`. + async fn scan_peer_addresses(&self, ns: Namespace, prefix: &str) -> StoreResult>; +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl ServiceStore 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 + } + + async fn create_device_keys(&self, blob: &[u8]) -> StoreResult<()> { + self.create_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 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 + } + + 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. +#[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])>, +} + +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 + /// pair-success response. + pub signed_identity: Vec, + 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()) +} + +/// 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}") +} + +/// 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 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<()>, + locks: Arc, + 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, 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 { + 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()), + } + } + + fn with_keys(store: Arc, keys: DeviceKeys) -> Self { + Self { + store, + keys: RwLock::new(Arc::new(keys)), + device_write: Mutex::new(()), + locks: Arc::new(KeyedLocks::default()), + sender_key_locks: Arc::new(KeyedLocks::default()), + } + } + + async fn stores(&self) -> Stores { + self.stores_over(self.store.clone()).await + } + + pub(crate) async fn stores_over(&self, store: Arc) -> Stores { + Stores { + store, + keys: self.keys.read().await.clone(), + sender_key_locks: self.sender_key_locks.clone(), + } + } + + pub(crate) fn store(&self) -> &Arc { + &self.store + } + + /// 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 { + 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> { + 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() + } + + /// 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> { + self.generate_fresh_prekeys(count).await + } + + /// 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> { + self.mark_fresh_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) + } + + /// 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).await? { + if let Some(device) = device_of(&address, user, server) { + found.push((address, device)); + } + } + } + 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(()); + } + 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(()) + } + + /// 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?) + } + + /// 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 _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?; + 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 bundle = bundle.to_prekey_bundle()?; + let mut sessions = self.stores_over(staged.clone()).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. The session update and the + /// consumed prekey's removal commit together. + pub async fn decrypt( + &self, + sender: &ProtocolAddress, + kind: EncKind, + ciphertext: &[u8], + ) -> Result { + 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?; + 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 => { + 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? + } + }; + // 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 { + staged.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 _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?; + 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 _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()) + } + + /// 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 _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()) + } + + /// 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 _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(()) + } + + /// 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, + group: &str, + sender: &ProtocolAddress, + ciphertext: &[u8], + ) -> Result, OpsError> { + let name = SenderKeyName::from_parts(group, sender.as_str()); + 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?) + } + + /// 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, + })?; + // 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..ecf8482e --- /dev/null +++ b/wacore/signal-ops/src/outbound.rs @@ -0,0 +1,614 @@ +//! 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::RemoteBundle; +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::{ + 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 _; +use wacore::types::message::{AddressingMode, EditAttribute}; +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: `[inserted ms, u64 BE][has_secret][secret (32 bytes, +/// when set)][message]`. +fn encode_sent(message: &[u8], secret: Option<&[u8; 32]>) -> Vec { + 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); + out.extend_from_slice(secret); + } + None => out.push(0), + } + out.extend_from_slice(message); + 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:#}")) +} + +/// 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, +} + +/// 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, + 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}") +} + +impl SignalOps { + async fn own_identity(&self) -> Result { + let keys = self.keys().await; + 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 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()) + })?; + 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()]) + .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()); + + // 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) = ( + 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) = decode_sent(&record)?; + waproto::codec::message_decode(body) + .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 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 + // 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 _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)) + { + 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 _held = self.lock_keys(vec![group_key(&group.to_string())]).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(()) + } + + /// 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(); + + // 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?; + } 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}"))) + } + + /// 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/src/prekeys.rs b/wacore/signal-ops/src/prekeys.rs new file mode 100644 index 00000000..7e5a90d8 --- /dev/null +++ b/wacore/signal-ops/src/prekeys.rs @@ -0,0 +1,344 @@ +//! 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 + } + + 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, + 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/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/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 new file mode 100644 index 00000000..1b20f77e --- /dev/null +++ b/wacore/signal-ops/tests/end_to_end.rs @@ -0,0 +1,372 @@ +//! 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, +}; +mod common; +use common::pair_success_container; +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" + ); +} + +#[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 { .. }))); +} + +#[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", + "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(); + 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(); +} + +#[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-ops/tests/prekey_upload.rs b/wacore/signal-ops/tests/prekey_upload.rs new file mode 100644 index 00000000..51abe0cc --- /dev/null +++ b/wacore/signal-ops/tests/prekey_upload.rs @@ -0,0 +1,830 @@ +//! 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, + 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; +type ReadGate = (oneshot::Sender<()>, oneshot::Receiver<()>); + +#[derive(Default)] +struct Records { + inner: MemoryRecordStore, + fail: AtomicUsize, + 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); + 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, + 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 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 { + 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); + }); +} diff --git a/wacore/signal-ops/tests/receive.rs b/wacore/signal-ops/tests/receive.rs new file mode 100644 index 00000000..3993bba9 --- /dev/null +++ b/wacore/signal-ops/tests/receive.rs @@ -0,0 +1,331 @@ +//! 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()); + 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( + 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); +} + +#[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 new file mode 100644 index 00000000..0ddb1958 --- /dev/null +++ b/wacore/signal-ops/tests/send.rs @@ -0,0 +1,616 @@ +//! 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}; +mod common; +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 { + 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) + .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, own_lid).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, + group: Option>, +} + +#[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> { + self.group + .clone() + .ok_or_else(|| anyhow::anyhow!("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() +} + +fn parse(stanza: &[u8]) -> wacore_binary::Node { + wacore_binary::marshal::unmarshal_packed_ref(stanza) + .unwrap() + .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() + .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()) +} + +/// 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()), + ..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)]), + group: None, + }; + 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)); + } + + 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")] +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(), + group: None, + }; + let to: Jid = "222@s.whatsapp.net".parse().unwrap(); + assert!( + ops.send_direct(&TokioRuntime, &client, &to, &text("x"), "ID") + .await + .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") + ); + + // 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") + .await + .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()); +} + +/// 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/Cargo.toml b/wacore/signal-wasm/Cargo.toml new file mode 100644 index 00000000..5a4460fc --- /dev/null +++ b/wacore/signal-wasm/Cargo.toml @@ -0,0 +1,42 @@ +[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] +anyhow = { workspace = true } +async-trait = { workspace = true } +buffa = { 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" +wasm-bindgen-futures = "0.4" +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 new file mode 100644 index 00000000..8a1c2730 --- /dev/null +++ b/wacore/signal-wasm/src/adapters.rs @@ -0,0 +1,311 @@ +//! `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, RecordExists, 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; + + // 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, + 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 error_code(error: &JsValue) -> Option { + Reflect::get(error, &JsValue::from_str("code")) + .ok() + .and_then(|c| c.as_string()) +} + +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), + 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())); + 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| 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), + }) + } +} + +#[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..31a0dcf0 --- /dev/null +++ b/wacore/signal-wasm/src/convert.rs @@ -0,0 +1,301 @@ +//! 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::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::Send(message) => coded_error("send", &message), + 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 +} + +/// 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 { + 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. +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")); + } + 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, + }) +} + +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}"))) +} + +/// 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 new file mode 100644 index 00000000..50710e04 --- /dev/null +++ b/wacore/signal-wasm/src/lib.rs @@ -0,0 +1,833 @@ +//! 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. +//! 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; +mod convert; +mod send; + +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, ReceiveKind, ReceiveRequest, Received, 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" | "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. 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; + 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; +} +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; + +struct Inner { + ops: SignalOps, + records: Arc, + lease: RefCell, + store: Arc, +} + +/// 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 opened = match SignalOps::open(store.clone()).await { + Ok(ops) => Ok(ops), + Err(OpsError::NotInitialised) if create => SignalOps::create(store.clone()).await, + Err(e) => Err(e), + }; + let ops = match opened { + Ok(ops) => ops, + 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)); + } + }; + Ok(SignalDevice { + inner: Rc::new(Inner { + ops, + records, + lease: RefCell::new(lease), + store, + }), + }) + } + + /// 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)) + }) + } + + /// 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))) + }) + } + + /// 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(); + 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()) + }) + } + + /// Decrypt, buffer and classify one received payload. Resolves + /// `{ status: "message", receiptKey, message, isSkdmOnly, identityChanged, + /// 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. `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, + chat: String, + user: String, + device: u32, + kind: String, + 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, + "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(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( + &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), + ); + 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()) + }) + } + + /// 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)) + }) + } + + /// 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) + }) + } + + /// 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)] + 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) + }) + } + + /// 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)] + 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/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 new file mode 100644 index 00000000..a4acd076 --- /dev/null +++ b/wacore/signal-wasm/tests/node.test.mjs @@ -0,0 +1,1148 @@ +// 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"; +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, + ), + ); +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 { + 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) { + 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()); + 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), + ); + } +} + +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"], + ); + 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, and the lease is released", async () => { + const backend = new MemoryBackend(); + 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, + ), + (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 () => { + 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", + ); +}); + +// 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); +}); + +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", + ); +}); + +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); +}); + +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); +}); + +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.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", + ); + 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.markPrekeysUploaded(Uint32Array.of(fresh[0].id)); + assert.equal(await reopened.uploadedPrekeyCount(), 4); + 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 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)), + ); + 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; 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 { 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()) }); }