feat(signal): Baileys drop-in session compatibility#20
Conversation
Keep Signal sessions/sender-keys on disk in Baileys' upstream JSON format so users can revert to Baileys without redoing sessions. Bidirectional, lossless migration implemented in the bridge over public whatsapp-rust core APIs (core unchanged); skipped-key seeds ride in a self-validating bridge-local sidecar. Also adapts the bridge to the whatsapp-rust 0.6 DecryptionResult API.
📝 WalkthroughWalkthroughAdds a new ChangesBaileys ↔ wacore Session Interop
Sequence Diagram(s)sequenceDiagram
rect rgba(70, 130, 180, 0.5)
note over SessionCipher,JsStorageAdapter: Phase 1 – pre-decrypt snapshot
end
SessionCipher->>JsStorageAdapter: snapshot_skip(address, message)
JsStorageAdapter->>JsStorageAdapter: skip_candidate per chain → Vec<SkipSnapshot>
JsStorageAdapter-->>SessionCipher: snapshots
rect rgba(180, 100, 70, 0.5)
note over SessionCipher,libsignal: Decrypt
end
SessionCipher->>libsignal: message_decrypt_prekey / message_decrypt_signal
libsignal-->>SessionCipher: DecryptionResult (plaintext)
rect rgba(70, 160, 100, 0.5)
note over SessionCipher,cached_seeds: Phase 2 – post-auth seed commit
end
SessionCipher->>JsStorageAdapter: commit_skip_snapshot(address, snapshots, session)
JsStorageAdapter->>JsStorageAdapter: derive MessageKeySeed via HMAC, merge_seeds
JsStorageAdapter->>cached_seeds: store merged SessionSeeds
SessionCipher->>JsStorageAdapter: repersist_session_json(address)
JsStorageAdapter->>legacy_session: record_to_legacy_json(record, seeds)
legacy_session-->>JsStorageAdapter: Baileys JSON { _sessions, version }
JsStorageAdapter->>JS Storage: storeSession(address, json)
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/dropin_store.test.ts`:
- Around line 96-101: The isTrustedIdentity method in LibsignalStore has a
misleading name that suggests it performs a boolean query, but it actually
registers and stores the identity key on first call (a necessary side effect for
subsequent SessionCipher operations). To clarify the intent, either rename the
method to trustIdentity() or ensureIdentity() to better reflect its registration
behavior, or add an explicit inline comment at each call site explaining that
the side effect of registering the identity is intentional and required for
setup. Apply this change wherever isTrustedIdentity is currently being called.
In `@test/helpers/dropin_storage.ts`:
- Around line 24-29: The loadSession, storeSession methods (and their identity
storage counterparts loadIdentity, storeIdentity in the same file) are not
cloning mutable objects at storage boundaries, allowing caller-side mutations to
modify persisted state without explicit store calls. Fix this by cloning the
session and identity objects when they are returned from load methods (to
prevent caller modifications from affecting the stored reference) and when they
are stored in store methods (to prevent mutations after storage from changing
the persisted state). Use a deep clone utility appropriate for your environment
to ensure all nested properties are copied, not just shallow references.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8b8efe30-26ce-464e-a2c1-576e402f3a12
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (10)
src/legacy_session.rssrc/lib.rssrc/session_cipher.rssrc/storage_adapter.rstest/dropin_gaps.test.tstest/dropin_store.test.tstest/helpers/dropin_storage.tstest/helpers/libsignal_store.tstest/legacy_roundtrip.test.tstest/skipped_key_migration.test.ts
There was a problem hiding this comment.
5 issues found across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…mit; stronger tests - prekey removal after a successful pkmsg decrypt is best-effort: a failure no longer propagates and drop the already-delivered plaintext. - extract the post-auth skip-seed commit+repersist into JsStorageAdapter::commit_skipped, shared by both decrypt paths. - [validate] test now corrupts a real cached seed and asserts the export drops exactly it (keeping untampered ones), instead of only checking a clean session. - skipped_key_migration test uses DropInStorage (no storeSessionRaw) so it exercises the drop-in migrate + JSON write-back path.
…ity, perf) - merge_seeds: O(n) insert (existing-index set) instead of O(n²) linear find per seed — removes an authenticated CPU amplification on a large gap. - snapshot_skip: capture new-DH-ratchet gaps decrypted via a promoted previous session too (export self-validation drops wrong-session candidates); gate the previous-session loop on previous_session_count() so the common case is free. - migration: invalid base64 now fails the import instead of silently producing an empty (undecryptable) session. - sidecar key cap raised to wacore's 2050 ceiling so it never drops a key the record still holds. - repersist failure is logged (was silently swallowed); still best-effort. - importLegacySession: doc note that the record is export-round-trip only.
The real Baileys consumer's storeSession calls record.serialize() and implements no storeSessionRaw, so the previous "no storeSessionRaw => drop-in JSON" default handed it a plain object and crashed every session write. Restore the native default: storeSession gets a SessionRecord (proto) unless the store opts in with `dropInBaileysFormat: true` (JSON) or implements storeSessionRaw (raw bytes). Gate seed capture / repersist / sender-key JSON on the same opt-in. Add a regression test mirroring the Baileys signalStorage contract.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@RFC-expose-session-info.md`:
- Around line 133-165: The session_info method currently returns
Result<Option<SessionInfo>, JsValue> which generates TypeScript type SessionInfo
| undefined, but this mismatches Baileys' contract requiring SessionInfo | null.
You must pick one approach: Option A — keep the current implementation and
update the doc comment for session_info to explicitly state that undefined is
returned (not a bug) and that Baileys normalizes it with ?? null, or Option B —
refactor the session_info method to use JsValue::NULL instead of Rust's Option
so the generated type is SessionInfo | null, matching Baileys' contract directly
without adapter code. Whichever approach you choose, update the acceptance
criterion in §8 to explicitly state either "undefined when no open session" or
"null when...", removing the ambiguous dual mention of both.
In `@test/legacy_proto_storage.test.ts`:
- Around line 40-47: The code is storing mutable Uint8Array references directly
in this.identities at lines 40 and 46. This causes aliasing issues where later
mutations by callers silently alter the trusted identity state, making
round-trip persistence tests flaky. In both places where this.identities.set(id,
key) is called (in the unnamed method returning boolean and in the trustIdentity
method), convert the key parameter to a buffer copy or similar immutable
representation before storing it. This ensures that external mutations of the
original array do not affect the stored identity state and keeps the
persistence/mutation assertions stable.
- Around line 72-102: The test starting with "hands storeSession a SessionRecord
(.serialize works) and round-trips" currently validates only string plaintext
encryption and proto serialization. Expand this test to include explicit binary
payload testing (not just string data converted to Buffer) and add persistence
replay paths to verify that stored SessionRecord instances can be retrieved and
reused across multiple encrypt/decrypt cycles. Add test cases that mutate the
payload and perform round-trip persistence checks following the same pattern as
the existing string plaintext validation to comply with the coding guidelines
for round-trip coverage of both binary and string content with
persistence-oriented checks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: bd34daa9-81ac-43ca-a68b-ca53c6210f50
📒 Files selected for processing (5)
RFC-expose-session-info.mdsrc/storage_adapter.rstest/dropin_gaps.test.tstest/helpers/dropin_storage.tstest/legacy_proto_storage.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- test/helpers/dropin_storage.ts
- test/dropin_gaps.test.ts
- src/storage_adapter.rs
There was a problem hiding this comment.
1 issue found across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
8fb4493 to
cf13cee
Compare
…ionId) Restores the libsignal-node getOpenSession().indexInfo.baseKey/registrationId surface that Baileys' retry protections read (reg-id mismatch + base-key collision in messages-recv). The WASM SessionRecord previously exposed only haveOpenSession()/serialize(), so Baileys' getSessionInfo() was forced to return null, silently disabling both checks. alice_base_key is the shared X3DH session index (set on both initiator and responder sides in wacore ratchet init), and remote_registration_id is the peer device id from the prekey message/bundle. Returns undefined when there is no open session (mirrors haveOpenSession()). Test: bridge<->bridge pair asserts both peers see the same 33-byte baseKey and cross-matched registrationIds; empty/legacy-JSON records return undefined.
- cargo fmt --all: fixes the CI 'Rust Format & Clippy' gate (session_record.rs sessionInfo() Reflect::set wrapping + a pre-existing storage_adapter.rs line). - Review nits (CodeRabbit/cubic): clone the Uint8Array before storing it as a trusted identity in LegacyProtoStorage + DropInStorage helpers, so caller-side buffer reuse can't silently mutate persisted state. - Expand the proto-storage round-trip to replay both ciphers off their persisted sessions with a non-UTF8 binary payload.
Problem
The bridge wraps the whatsapp-rust core, which stores Signal sessions as the official WhatsApp protobuf. Baileys upstream stores them as its own on-disk JSON. So adopting the bridge meant a one-way migration: users could not revert to Baileys without redoing every session. The existing import also corrupted skipped (out-of-order) message keys — it stuffed the 32-byte seed into
cipherKeyand zeroed mac/iv — and never read its string-keyedmessageKeysmap, so any cached key broke decryption.Separately, the bridge's
SessionRecordexposed onlyhaveOpenSession()/serialize(), so Baileys'getSessionInfo()could not read the open session'sbaseKey/registrationIdand was forced to returnnull, silently disabling two retry protections (registration-id mismatch and base-key collision inmessages-recv).Fix
All done in the bridge over public whatsapp-rust core APIs — the core is unchanged.
Session storage — three modes (default is safe for the current Baileys consumer):
SessionRecord— the shape Baileys'storeSessionalready serializes. No behavior change for existing consumers.storeSessionRaw: raw proto bytes (skips the wrapper, slightly faster).dropInBaileysFormat: true: Baileys' on-disk JSON — the revert path, so a user can switch back to Baileys without redoing sessions.Bidirectional, lossless session migration (Baileys JSON ↔ core record): full
_sessions(current + archived),pendingPreKey,baseKeyType,lastRemoteEphemeralKey, closed receiver chains,registrationId, identity, and the chainKey counter off-by-one.Skipped-key seeds: the core stores the post-HKDF split and HKDF is one-way, so seeds ride in a small bridge-local sidecar. They're captured at decrypt time (reusing the core's own chain stepping / DH ratchet), and the export self-validates every seed by re-deriving it against the split the core stored — a stale/wrong seed is dropped (peer retries that one message) instead of written as bad key material. Capture is split pre-decrypt (cheap snapshot) / post-auth (commit), so a forged message can't drive the DH ratchet, the stepping, or any cache/disk write.
Sender-key (group) revert: write the Baileys sender-key JSON shape.
SessionRecord.sessionInfo(): exposes the open session'sbaseKey+ remoteregistrationId(the libsignal-nodegetOpenSession().indexInfo.baseKey/registrationIdequivalents) so Baileys' retry protections work again.baseKeyis the shared X3DH session index (set on both the initiator and responder sides in the core ratchet); returnsundefinedwhen there is no open session.0.6 API: adapts the bridge to the whatsapp-rust 0.6
DecryptionResultAPI (extract plaintext, caller-side prekey removal).Tests
test/skipped_key_migration.test.ts,test/legacy_roundtrip.test.ts,test/dropin_store.test.ts,test/dropin_gaps.test.ts— round-trip and drop-in coverage, including a real Baileys-upstream signal session decrypting JSON the bridge wrote (cached + forward messages) and per-gap cases (archived sessions, closed chains, baseKeyType, timestamps, 32→33-byte keys).test/legacy_proto_storage.test.ts— guards the default proto mode against the exact current BaileysstoreSessioncontract (SessionRecord.serialize()), incl. a non-UTF8 binary round-trip.test/session_info.test.ts— bridge↔bridge pair asserts both peers see the same 33-bytebaseKeyand cross-matchedregistrationIds; empty/legacy-JSON records returnundefined.Verify
cargo fmt --all -- --check+cargo clippy --target wasm32-unknown-unknownclean; whatsapp-rust core untouched.bun run build+bun test: 186 pass / 0 fail (32 media skips).Notes (pre-existing, out of scope)
dropInBaileysFormat: true, persist the objectstoreSessionreceives, and adapt its directSessionRecord.deserialize/.serializeusages); the bridge already supports both sides.Summary by CodeRabbit
Release Notes