|
| 1 | +# RFC: Expose session metadata (`baseKey` + `registrationId`) from the WASM `SessionRecord` |
| 2 | + |
| 3 | +| | | |
| 4 | +|---|---| |
| 5 | +| **Status** | Proposed — ready for evaluation & implementation | |
| 6 | +| **Target repo** | `whatsapp-rust-bridge` (`/home/jlucaso/projects/whatsapp-rust-bridge`) | |
| 7 | +| **Driven by** | Baileys (`/home/jlucaso/projects/Baileys`) libsignal → WASM migration (PR `feat-libsignal-wasm`) | |
| 8 | +| **Bridge version at time of writing** | npm `0.5.5` (proposed release: `0.5.6`) | |
| 9 | +| **Core dep** | `wacore-libsignal` from `github.com/jlucaso1/whatsapp-rust` (locked commit `a03672e`) | |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## 1. Summary |
| 14 | + |
| 15 | +Add one read-only method, `sessionInfo()`, to the WASM-exported `SessionRecord` class in the bridge. It deserializes the stored session and returns `{ baseKey: Uint8Array, registrationId: number }` for the current open session, or `null` when there is no open session. |
| 16 | + |
| 17 | +All the underlying data **already exists** in the `wacore-libsignal` `SessionRecord` type the bridge wraps; it simply is not crossing the WASM boundary today. This is a small additive change (≈15–25 lines of Rust + a rebuild). No protobuf/schema changes are needed. |
| 18 | + |
| 19 | +--- |
| 20 | + |
| 21 | +## 2. Background & context |
| 22 | + |
| 23 | +**Baileys** (a TypeScript WhatsApp Web library) is migrating its Signal protocol implementation from the native JS `libsignal` package to this bridge (`whatsapp-rust-bridge`, Rust→WASM). In the migration, the JS Signal session storage went from "native `SessionRecord` objects" to "opaque serialized bytes (`Uint8Array`) handed to/from the WASM `SignalStorage` adapter". |
| 24 | + |
| 25 | +An adversarial code review of that migration found a behavioral regression (internally tracked as **finding C**): |
| 26 | + |
| 27 | +- The native `libsignal-node` `SessionRecord` exposed `getOpenSession()` whose `indexInfo.baseKey` and `registrationId` Baileys read to power two **retry protections** in incoming-message handling. |
| 28 | +- The bridge's WASM `SessionRecord` exposes only `deserialize()`, `serialize()`, and `haveOpenSession()` — **not** the open session's `baseKey` or `registrationId`. |
| 29 | +- Because Baileys' `loadSession` now returns raw bytes, its `getSessionInfo()` can no longer obtain those fields and is forced to return `null` always, silently disabling both protections. |
| 30 | + |
| 31 | +This RFC proposes restoring the capability **at the bridge layer**, because that is the only place the data is reachable. |
| 32 | + |
| 33 | +### The two Baileys retry protections that depend on this |
| 34 | + |
| 35 | +In `/home/jlucaso/projects/Baileys/src/Socket/messages-recv.ts` (around lines 1378–1405), `signalRepository.getSessionInfo(participant)` is used to: |
| 36 | + |
| 37 | +1. **Registration-id mismatch** — if the stored session's `registrationId` differs from the `registration` attribute on an incoming retry receipt, the stale session is deleted so it can be re-established. |
| 38 | +2. **Base-key collision on retry** — `messageRetryManager.saveBaseKey()` / `hasSameBaseKey()` use the session `baseKey` to detect when a peer is replaying against a session we've already rotated, forcing a fresh session. |
| 39 | + |
| 40 | +Both callers already guard on a `null` result, so today the code is *safe* but *degraded*: these protections are no-ops under the WASM backend. |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 3. Problem statement |
| 45 | + |
| 46 | +The Baileys consumer (`/home/jlucaso/projects/Baileys/src/Signal/libsignal.ts`) currently looks like this (post-review, with the degradation made explicit and documented): |
| 47 | + |
| 48 | +```ts |
| 49 | +// repository.getSessionInfo |
| 50 | +async getSessionInfo() { |
| 51 | + // The WASM SignalStorage persists sessions as opaque serialized bytes and the |
| 52 | + // whatsapp-rust-bridge SessionRecord API exposes only haveOpenSession()/serialize() — |
| 53 | + // it does NOT expose the open session's baseKey or registrationId. The |
| 54 | + // registration-id-mismatch and base-key-collision retry protections in messages-recv |
| 55 | + // therefore cannot be evaluated under the WASM backend; both call sites guard on a |
| 56 | + // null result and simply skip those checks (graceful degradation). |
| 57 | + // TODO(libsignal-wasm): expose baseKey + registrationId via the bridge (or persist |
| 58 | + // them alongside the session) to restore these retry protections. |
| 59 | + return null |
| 60 | +} |
| 61 | +``` |
| 62 | + |
| 63 | +The interface contract it must satisfy (`/home/jlucaso/projects/Baileys/src/Types/Signal.ts`): |
| 64 | + |
| 65 | +```ts |
| 66 | +getSessionInfo(jid: string): Promise<{ baseKey: Uint8Array; registrationId: number } | null> |
| 67 | +``` |
| 68 | + |
| 69 | +We want the bridge to provide enough surface that Baileys can implement this faithfully again. |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## 4. Why this belongs in the bridge (the data is already there) |
| 74 | + |
| 75 | +The bridge's WASM `SessionRecord` wrapper already deserializes the core type internally. See `/home/jlucaso/projects/whatsapp-rust-bridge/src/session_record.rs`: |
| 76 | + |
| 77 | +```rust |
| 78 | +use wacore_libsignal::protocol::SessionRecord as CoreSessionRecord; |
| 79 | + |
| 80 | +#[wasm_bindgen(js_name = SessionRecord)] |
| 81 | +pub struct SessionRecord { |
| 82 | + pub(crate) serialized_data: Vec<u8>, |
| 83 | +} |
| 84 | + |
| 85 | +// existing method — proves the deserialize+query pattern already used here: |
| 86 | +#[wasm_bindgen(js_name = haveOpenSession)] |
| 87 | +pub fn have_open_session(&self) -> bool { |
| 88 | + CoreSessionRecord::deserialize(&self.serialized_data) |
| 89 | + .map(|record| record.session_state().is_some()) |
| 90 | + .unwrap_or(false) |
| 91 | +} |
| 92 | +``` |
| 93 | + |
| 94 | +The core `CoreSessionRecord` (from `wacore-libsignal`) **already exposes** exactly what is needed. Evidence (absolute path, commit `a03672e`): |
| 95 | + |
| 96 | +`/home/jlucaso/.cargo/git/checkouts/whatsapp-rust-acce85bd6aff6984/a03672e/wacore/libsignal/src/protocol/state/session.rs` |
| 97 | + |
| 98 | +| Need | Core method | Line | |
| 99 | +|---|---|---| |
| 100 | +| open-session presence (→ `null` case) | `SessionRecord::session_state(&self) -> Option<&SessionState>` | `:691` | |
| 101 | +| `registrationId` | `SessionRecord::remote_registration_id(&self) -> Result<u32, SignalProtocolError>` | `:854` | |
| 102 | +| `baseKey` | `SessionRecord::alice_base_key(&self) -> Result<&[u8], SignalProtocolError>` | `:918` | |
| 103 | + |
| 104 | +`alice_base_key` is the `libsignal-node` `indexInfo.baseKey` equivalent (the X3DH base key that indexes the session). It is stored on **both** the initiator and responder sides (set in the `SessionState` constructor at `state/session.rs:136`, `alice_base_key: Some(alice_base_key.serialize().to_vec())`), so it is a stable per-session identifier — which is exactly how the Baileys collision/mismatch checks use it. |
| 105 | + |
| 106 | +> ⚠️ Line numbers above are for the **locked** checkout `a03672e`. When you implement, confirm the methods still exist on whatever `wacore-libsignal` revision the bridge currently builds against (the `main` branch may have moved). The method *names* are the stable contract. |
| 107 | +
|
| 108 | +--- |
| 109 | + |
| 110 | +## 5. Proposed change |
| 111 | + |
| 112 | +**File to modify:** `/home/jlucaso/projects/whatsapp-rust-bridge/src/session_record.rs` |
| 113 | +Add a new method to the existing `impl SessionRecord` block. Two implementation styles are already established in this codebase; pick whichever fits the house style. **Option A (Tsify) is recommended** for a clean, typed `.d.ts`. |
| 114 | + |
| 115 | +### Option A — Tsify struct (recommended) |
| 116 | + |
| 117 | +The bridge already uses `tsify_next::Tsify` for typed return objects — see `/home/jlucaso/projects/whatsapp-rust-bridge/src/key_helper.rs` (`use tsify_next::Tsify;`, `#[derive(Serialize, Tsify)] #[tsify(into_wasm_abi)]`, `#[tsify(type = "Uint8Array")]`). Mirror that: |
| 118 | + |
| 119 | +```rust |
| 120 | +use serde::Serialize; |
| 121 | +use tsify_next::Tsify; |
| 122 | + |
| 123 | +#[derive(Debug, Clone, Serialize, Tsify)] |
| 124 | +#[tsify(into_wasm_abi)] |
| 125 | +pub struct SessionInfo { |
| 126 | + #[tsify(type = "Uint8Array")] |
| 127 | + pub base_key: Vec<u8>, |
| 128 | + pub registration_id: u32, |
| 129 | +} |
| 130 | + |
| 131 | +#[wasm_bindgen(js_class = SessionRecord)] |
| 132 | +impl SessionRecord { |
| 133 | + /// Returns the open session's base key + remote registration id, or `null` |
| 134 | + /// (`undefined` on the JS side) when there is no current/open session. |
| 135 | + #[wasm_bindgen(js_name = sessionInfo)] |
| 136 | + pub fn session_info(&self) -> Result<Option<SessionInfo>, JsValue> { |
| 137 | + let record = CoreSessionRecord::deserialize(&self.serialized_data) |
| 138 | + .map_err(|e| JsValue::from_str(&e.to_string()))?; |
| 139 | + |
| 140 | + if record.session_state().is_none() { |
| 141 | + return Ok(None); |
| 142 | + } |
| 143 | + |
| 144 | + let base_key = record |
| 145 | + .alice_base_key() |
| 146 | + .map_err(|e| JsValue::from_str(&e.to_string()))? |
| 147 | + .to_vec(); |
| 148 | + let registration_id = record |
| 149 | + .remote_registration_id() |
| 150 | + .map_err(|e| JsValue::from_str(&e.to_string()))?; |
| 151 | + |
| 152 | + Ok(Some(SessionInfo { base_key, registration_id })) |
| 153 | + } |
| 154 | +} |
| 155 | +``` |
| 156 | + |
| 157 | +Generated TS (in `/home/jlucaso/projects/whatsapp-rust-bridge/pkg/whatsapp_rust_bridge.d.ts`): |
| 158 | + |
| 159 | +```ts |
| 160 | +export interface SessionInfo { base_key: Uint8Array; registration_id: number; } |
| 161 | +export class SessionRecord { |
| 162 | + // ...existing... |
| 163 | + sessionInfo(): SessionInfo | undefined; |
| 164 | +} |
| 165 | +``` |
| 166 | + |
| 167 | +> Note the field names will be `snake_case` (`base_key`, `registration_id`) unless you add `#[serde(rename_all = "camelCase")]` to the struct. **Recommended:** add `#[serde(rename_all = "camelCase")]` so the JS surface is `{ baseKey, registrationId }`, which lets Baileys consume it directly without remapping. |
| 168 | +
|
| 169 | +### Option B — `Object` + `Reflect` (matches `session_cipher.rs`) |
| 170 | + |
| 171 | +If you prefer to avoid a new struct, mirror the `{ type, body }` return already in `/home/jlucaso/projects/whatsapp-rust-bridge/src/session_cipher.rs` (around line 63, `Object::new()` + `Reflect::set`, with `#[wasm_bindgen(... typescript_type = "...")]`): |
| 172 | + |
| 173 | +```rust |
| 174 | +#[wasm_bindgen(js_name = sessionInfo)] |
| 175 | +pub fn session_info(&self) -> Result<JsValue, JsValue> { |
| 176 | + let record = CoreSessionRecord::deserialize(&self.serialized_data) |
| 177 | + .map_err(|e| JsValue::from_str(&e.to_string()))?; |
| 178 | + if record.session_state().is_none() { |
| 179 | + return Ok(JsValue::NULL); |
| 180 | + } |
| 181 | + let base_key = record.alice_base_key().map_err(|e| JsValue::from_str(&e.to_string()))?; |
| 182 | + let registration_id = record.remote_registration_id().map_err(|e| JsValue::from_str(&e.to_string()))?; |
| 183 | + |
| 184 | + let obj = js_sys::Object::new(); |
| 185 | + js_sys::Reflect::set(&obj, &JsValue::from_str("baseKey"), &js_sys::Uint8Array::from(base_key))?; |
| 186 | + js_sys::Reflect::set(&obj, &JsValue::from_str("registrationId"), &JsValue::from_f64(registration_id as f64))?; |
| 187 | + Ok(obj.into()) |
| 188 | +} |
| 189 | +``` |
| 190 | + |
| 191 | +For a typed surface with Option B, attach a `typescript_type = "{ baseKey: Uint8Array; registrationId: number } | null"` annotation (see how `session_cipher.rs` annotates its `EncryptResult`). |
| 192 | + |
| 193 | +### Semantics to preserve |
| 194 | + |
| 195 | +- **No open session ⇒ `null`** (not an empty object, not a throw). Baileys treats `null` as "no info, skip the checks". |
| 196 | +- `alice_base_key()` on the core `SessionState` does `unwrap_or(&[])` (`state/session.rs:143`), so an established-but-base-key-less state would yield an empty slice. This is acceptable: Baileys checks `info.baseKey` length/truthiness. If you want to be stricter, return `null` when `base_key.is_empty()` — your call. |
| 197 | + |
| 198 | +--- |
| 199 | + |
| 200 | +## 6. Baileys-side consumption (the contract you must satisfy) |
| 201 | + |
| 202 | +Once the bridge ships, `/home/jlucaso/projects/Baileys/src/Signal/libsignal.ts` `getSessionInfo` becomes (with `#[serde(rename_all = "camelCase")]` / Option B): |
| 203 | + |
| 204 | +```ts |
| 205 | +async getSessionInfo(jid: string) { |
| 206 | + const addr = jidToSignalProtocolAddress(jid).toString() |
| 207 | + const session = await storage.loadSession(addr) // Uint8Array | null |
| 208 | + if (!session) { |
| 209 | + return null |
| 210 | + } |
| 211 | + return SessionRecord.deserialize(session).sessionInfo() ?? null |
| 212 | +} |
| 213 | +``` |
| 214 | + |
| 215 | +That restores the `{ baseKey, registrationId } | null` contract and re-enables both retry protections. (This Baileys-side change is **out of scope for the bridge agent** — it's listed only so you understand the consumer's expected shape.) |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +## 7. Build, test, release |
| 220 | + |
| 221 | +**Build** (from `/home/jlucaso/projects/whatsapp-rust-bridge`): |
| 222 | + |
| 223 | +```bash |
| 224 | +bun run build # prebuild + build:wasm (wasm-pack, dual simd/nosimd) + build:ts + tsc |
| 225 | +``` |
| 226 | + |
| 227 | +`build:wasm` (`scripts/build-wasm.mjs`) runs `wasm-pack build --target web --out-dir pkg --no-pack --no-opt` and regenerates `pkg/whatsapp_rust_bridge.d.ts`. Verify the new `sessionInfo` / `SessionInfo` appears there. |
| 228 | + |
| 229 | +**Test** — add a case to `/home/jlucaso/projects/whatsapp-rust-bridge/test/session_record.test.ts`. Use `/home/jlucaso/projects/whatsapp-rust-bridge/test/session_builder.test.ts` and `test/helpers/fake_storage.ts` as references for building a *real* established session (process a prekey bundle / run a SessionBuilder), then assert: |
| 230 | + |
| 231 | +- a record with an open session returns `{ baseKey: Uint8Array(len>0), registrationId: <expected u32> }`; |
| 232 | +- a freshly-`deserialize([])`'d / empty record returns `null` (mirrors `haveOpenSession() === false`); |
| 233 | +- ideally, parity: the `baseKey` bytes and `registrationId` match what `@whiskeysockets/libsignal-node` (already a devDependency) reports via `getOpenSession().indexInfo.baseKey` / `registrationId` for the same established session — this directly proves drop-in equivalence with what Baileys previously read. |
| 234 | + |
| 235 | +**Release**: bump `package.json` `0.5.5 → 0.5.6`, publish, then bump `whatsapp-rust-bridge` in `/home/jlucaso/projects/Baileys/package.json` (currently pinned `0.5.4`) and re-run Baileys' `yarn lint && yarn test`. |
| 236 | + |
| 237 | +--- |
| 238 | + |
| 239 | +## 8. Acceptance criteria |
| 240 | + |
| 241 | +- [ ] `SessionRecord.sessionInfo()` exists on the WASM class and is in `pkg/whatsapp_rust_bridge.d.ts`. |
| 242 | +- [ ] Returns `{ baseKey: Uint8Array, registrationId: number }` for an open session; `null`/`undefined` when no open session. |
| 243 | +- [ ] JS field names are `baseKey` / `registrationId` (camelCase) so Baileys consumes the object directly. |
| 244 | +- [ ] New test in `test/session_record.test.ts` covering open-session and no-session cases (parity vs `libsignal-node` if practical). |
| 245 | +- [ ] `bun run build` succeeds; existing tests still pass. |
| 246 | +- [ ] Version bumped to `0.5.6`. |
| 247 | + |
| 248 | +--- |
| 249 | + |
| 250 | +## 9. Risks / open questions |
| 251 | + |
| 252 | +1. **`alice_base_key` vs native `indexInfo.baseKey`** — believed equivalent (both are the X3DH base key stored in the session structure; native libsignal indexes sessions by it). The parity test against `libsignal-node` is the cheapest way to confirm exact byte equivalence. If they ever differ, what matters for Baileys is only that the value is a *stable per-session identifier*, which `alice_base_key` is. |
| 253 | +2. **Empty base key** — `alice_base_key()` returns `&[]` when unset; decide whether to surface `null` in that edge case (see §5). |
| 254 | +3. **`wacore-libsignal` revision drift** — confirm the three core methods (`session_state`, `remote_registration_id`, `alice_base_key`) exist on the revision the bridge currently builds against; names are the stable contract, line numbers are not. |
| 255 | +4. **`Result<Option<Tsify>, JsValue>` ergonomics** — confirm this return shape generates the intended `SessionInfo | undefined` with the bridge's `tsify_next`/`wasm-bindgen` versions; if awkward, fall back to Option B. |
| 256 | + |
| 257 | +--- |
| 258 | + |
| 259 | +## Appendix — absolute file reference map |
| 260 | + |
| 261 | +**Bridge (`whatsapp-rust-bridge`)** |
| 262 | +- Modify: `/home/jlucaso/projects/whatsapp-rust-bridge/src/session_record.rs` |
| 263 | +- Style refs: `/home/jlucaso/projects/whatsapp-rust-bridge/src/key_helper.rs` (Tsify), `/home/jlucaso/projects/whatsapp-rust-bridge/src/session_cipher.rs` (Object+Reflect) |
| 264 | +- Generated types: `/home/jlucaso/projects/whatsapp-rust-bridge/pkg/whatsapp_rust_bridge.d.ts` |
| 265 | +- Build script: `/home/jlucaso/projects/whatsapp-rust-bridge/scripts/build-wasm.mjs` |
| 266 | +- Test target: `/home/jlucaso/projects/whatsapp-rust-bridge/test/session_record.test.ts` (helpers in `test/helpers/`) |
| 267 | +- Version: `/home/jlucaso/projects/whatsapp-rust-bridge/package.json` |
| 268 | + |
| 269 | +**Core (`wacore-libsignal`, commit `a03672e`)** |
| 270 | +- `/home/jlucaso/.cargo/git/checkouts/whatsapp-rust-acce85bd6aff6984/a03672e/wacore/libsignal/src/protocol/state/session.rs` |
| 271 | + - `session_state()` `:691` · `remote_registration_id()` `:854` · `alice_base_key()` `:918` · base-key set in ctor `:136` |
| 272 | + |
| 273 | +**Baileys (consumer)** |
| 274 | +- `/home/jlucaso/projects/Baileys/src/Signal/libsignal.ts` (`getSessionInfo`) |
| 275 | +- `/home/jlucaso/projects/Baileys/src/Types/Signal.ts` (`getSessionInfo` contract) |
| 276 | +- `/home/jlucaso/projects/Baileys/src/Socket/messages-recv.ts` (callers, ~lines 1378–1405) |
0 commit comments