Skip to content

feat(signal): Baileys drop-in session compatibility#20

Open
jlucaso1 wants to merge 8 commits into
masterfrom
feat/baileys-dropin-session-compat
Open

feat(signal): Baileys drop-in session compatibility#20
jlucaso1 wants to merge 8 commits into
masterfrom
feat/baileys-dropin-session-compat

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

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 cipherKey and zeroed mac/iv — and never read its string-keyed messageKeys map, so any cached key broke decryption.

Separately, the bridge's SessionRecord exposed only haveOpenSession()/serialize(), so Baileys' getSessionInfo() could not read the open session's baseKey/registrationId and was forced to return null, silently disabling two retry protections (registration-id mismatch and base-key collision in messages-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):

  • Default: native core proto bytes wrapped in a SessionRecord — the shape Baileys' storeSession already 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's baseKey + remote registrationId (the libsignal-node getOpenSession().indexInfo.baseKey/registrationId equivalents) so Baileys' retry protections work again. baseKey is the shared X3DH session index (set on both the initiator and responder sides in the core ratchet); returns undefined when there is no open session.

0.6 API: adapts the bridge to the whatsapp-rust 0.6 DecryptionResult API (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 Baileys storeSession contract (SessionRecord.serialize()), incl. a non-UTF8 binary round-trip.
  • test/session_info.test.ts — bridge↔bridge pair asserts both peers see the same 33-byte baseKey and cross-matched registrationIds; empty/legacy-JSON records return undefined.

Verify

  • cargo fmt --all -- --check + cargo clippy --target wasm32-unknown-unknown clean; whatsapp-rust core untouched.
  • bun run build + bun test: 186 pass / 0 fail (32 media skips).

Notes (pre-existing, out of scope)

  • Enabling the revert path for the real Baileys is a coordinated consumer-side change (set dropInBaileysFormat: true, persist the object storeSession receives, and adapt its direct SessionRecord.deserialize/.serialize usages); the bridge already supports both sides.
  • Per-address operation serialization is the consumer's responsibility (as Baileys does); the bridge does not queue concurrent same-address ops.
  • The seed-enrichment re-write after a gap decrypt is best-effort — the base session is always persisted correctly, so a failure only costs a one-message retry, never corruption.
  • A bare 32-byte public key (which Baileys upstream tolerates) is canonicalized to the 33-byte form on import.

Summary by CodeRabbit

Release Notes

  • New Features
    • Added interoperability helpers to export/import legacy session data with skipped-key seed preservation, including group sender-key conversion for better cross-store compatibility.
  • Bug Fixes
    • Updated decryption to correctly snapshot/commit skipped-key state, improving reliability for delayed and out-of-order messages.
    • Fixed session round-trips to preserve expected ratchet/session details and session openness vs archived state.
  • Tests
    • Added Bun test coverage for legacy round-trips, skipped-key migration, and drop-in storage compatibility.

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.
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds a new legacy_session Rust module that converts wacore protobuf RecordStructure and SenderKeyRecordStructure into Baileys-compatible legacy JSON. Introduces protobuf sidecar types for skipped-message HKDF seeds, a two-phase snapshot/commit mechanism in JsStorageAdapter and SessionCipher, refactors the legacy session import/export to produce both record bytes and seed sidecars for lossless round-trips, provides test helpers and comprehensive interop tests, and includes an RFC for exposing session metadata.

Changes

Baileys ↔ wacore Session Interop

Layer / File(s) Summary
Sidecar seed types and wacore → Baileys JSON conversion
src/legacy_session.rs, src/lib.rs
Defines SessionSeeds, SessionMeta, ChainSeeds, MessageKeySeed prost types; implements record_to_legacy_json, session_to_entry, chain_to_js, seed_matches_record validator, sender_key_record_to_legacy_json, buffer_json, and wasm entrypoints export_legacy_session / export_legacy_sender_key; exports the module from lib.rs.
Storage adapter skipped-key sidecar infrastructure
src/storage_adapter.rs
Adds SkipSnapshot enum, message_key_seed HMAC derivation, skip_candidate, cached_seeds field on JsStorageAdapter, and implements snapshot_skip, commit_skip_snapshot, merge_seeds, repersist_session_json, write_session_json, mark_base_key_ours; extends TypeScript SignalStorage interface with dropInBaileysFormat, optional storeSessionRaw, and flexible session type handling.
SessionCipher two-phase snapshot/commit decrypt
src/session_cipher.rs
Wraps both decrypt_prekey_whisper_message and decrypt_whisper_message in pre-decrypt snapshot_skip and post-auth commit_skip_snapshot + repersist_session_json calls; plaintext returned from decryption result; imports updated for PreKeyStore trait.
Legacy import/export with seed round-trip
src/storage_adapter.rs
Replaces single-pass legacy migration with legacy_entry_to_session, legacy_value_to_record, ensure_pubkey_33, legacy_message_keys helpers that extract seeds and metadata; import_legacy_session returns {record, seeds} as separate Uint8Arrays; load_session caches extracted seeds; store_session routes between raw and JSON-drop-in paths; sender-key drop-in converts to Baileys JSON via sender_key_record_to_legacy_json.
Test helpers
test/helpers/dropin_storage.ts, test/helpers/libsignal_store.ts
Adds DropInStorage (Baileys-style in-memory store with dropInBaileysFormat = true, structuredClone persistence, Buffer-equality TOFU trust, and Date.now() signed-prekey timestamps) and LibsignalStore with makeRunningLibsignalPair() helper producing a post-handshake running libsignal-node cipher pair.
Legacy session compatibility gaps and round-trip tests
test/dropin_gaps.test.ts, test/legacy_roundtrip.test.ts
Covers roundTrip behavior via deterministic makeEntry and roundTrip helpers: archived/open session preservation, baseKeyType retention, lastRemoteEphemeralKey exactness, closed chain handling, timestamp fidelity, seed tamper validation (self-validation), key normalization, receiver-chain evolution, group sender-key JSON persistence, pending initiator session survival, and field-by-field key material identity.
Bridge compatibility and skipped-key migration tests
test/dropin_store.test.ts, test/skipped_key_migration.test.ts, test/legacy_proto_storage.test.ts
Validates cold-cache reload, out-of-order delivery seed capture on existing and new ratchet chains, full libsignal-node decrypt compatibility from bridge-written JSON (message type and prekey clearance), migration of a running libsignal-node session with cached skipped keys, and preservation of the default native proto storage contract (SessionRecord.serialize() round-trip).
RFC: expose session metadata
RFC-expose-session-info.md
Proposes a new WASM SessionRecord.sessionInfo() method returning `{ baseKey: Uint8Array; registrationId: number }

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)
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Poem

🐇 A hop through the ratchet, a skip through the chain,
The seeds are all captured, none lost to the rain.
Old Baileys and wacore now share the same key,
Round-tripping in JSON, lossless and free.
🌿 No message forgotten, no cipher astray —
The rabbit ships sessions that last the whole day!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main feature: Baileys drop-in session compatibility. It directly summarizes the primary objective of enabling seamless session switching between the bridge and Baileys upstream.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/baileys-dropin-session-compat

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4b185ec and e826899.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • src/legacy_session.rs
  • src/lib.rs
  • src/session_cipher.rs
  • src/storage_adapter.rs
  • test/dropin_gaps.test.ts
  • test/dropin_store.test.ts
  • test/helpers/dropin_storage.ts
  • test/helpers/libsignal_store.ts
  • test/legacy_roundtrip.test.ts
  • test/skipped_key_migration.test.ts

Comment thread test/dropin_store.test.ts
Comment thread test/helpers/dropin_storage.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 11 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/session_cipher.rs Outdated
Comment thread test/helpers/dropin_storage.ts Outdated
Comment thread src/session_cipher.rs Outdated
Comment thread test/dropin_gaps.test.ts Outdated
Comment thread test/skipped_key_migration.test.ts Outdated
jlucaso1 added 5 commits June 16, 2026 09:24
…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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 8fc5a3e and 8fb4493.

📒 Files selected for processing (5)
  • RFC-expose-session-info.md
  • src/storage_adapter.rs
  • test/dropin_gaps.test.ts
  • test/helpers/dropin_storage.ts
  • test/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

Comment thread test/legacy_proto_storage.test.ts Outdated
Comment thread test/legacy_proto_storage.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread test/legacy_proto_storage.test.ts Outdated
@jlucaso1 jlucaso1 force-pushed the feat/baileys-dropin-session-compat branch from 8fb4493 to cf13cee Compare June 16, 2026 14:21
…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.
@WhiskeySockets WhiskeySockets deleted a comment from coderabbitai Bot Jun 16, 2026
- 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant