@oxidezap/baileyrs 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts +65 -0
  2. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts.map +1 -0
  3. package/lib/Utils/__tests__/_legacy-store-fixtures.js +107 -0
  4. package/lib/Utils/__tests__/_legacy-store-fixtures.js.map +1 -0
  5. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts +10 -0
  6. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts.map +1 -0
  7. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js +298 -0
  8. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js.map +1 -0
  9. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts +4 -19
  10. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts.map +1 -1
  11. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js +31 -97
  12. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js.map +1 -1
  13. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts +6 -27
  14. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts.map +1 -1
  15. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js +48 -175
  16. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js.map +1 -1
  17. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts +10 -28
  18. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts.map +1 -1
  19. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js +118 -297
  20. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js.map +1 -1
  21. package/lib/Utils/wrap-legacy-store.d.ts.map +1 -1
  22. package/lib/Utils/wrap-legacy-store.js +128 -273
  23. package/lib/Utils/wrap-legacy-store.js.map +1 -1
  24. package/package.json +4 -1
@@ -48,13 +48,15 @@ const STORE_MAP = {
48
48
  meta: 'bridge-meta'
49
49
  };
50
50
  // Stores where bridge data is passthrough binary (no conversion).
51
- // `sender_key` and `session` are intentionally excluded — both diverge in
52
- // on-disk format between Rust libsignal (protobuf `SenderKeyRecordStructure`
53
- // / `RecordStructure`) and JS libsignal (`SenderKeyRecord` UTF-8 JSON /
54
- // `SessionRecord` plain JS object). They route through converters so the
55
- // same auth folder remains readable from either implementation, which is
56
- // the whole point of `wrap-legacy-store`.
57
- const BINARY_STORES = new Set(['identity']);
51
+ // All Signal-protocol records (`identity`, `session`, `sender_key`) now
52
+ // route through converters because every one of them either:
53
+ // has a different key shape between bridge and upstream, OR
54
+ // has a different value-byte encoding (proto / JSON / 32-vs-33-byte
55
+ // prefix), OR
56
+ // both.
57
+ // Keeping the empty set as an extension point — future bridge stores that
58
+ // are byte-compatible with upstream can land here without a converter.
59
+ const BINARY_STORES = new Set();
58
60
  // Bridge-only stores — raw binary persisted through keys interface.
59
61
  // `lid_mapping` is intentionally excluded — it has a converter so the
60
62
  // `pn:`/`lid:` prefixed bridge keys translate to upstream's bare-userpart
@@ -214,42 +216,36 @@ const converters = {
214
216
  }
215
217
  }
216
218
  },
217
- // ── lid_mapping ──
218
- // Cross-impl converter for LID PN device mapping table.
219
- // • Bridge: keyed by `lid:{lidUser}` (value=JSON LidPnMappingEntry
220
- // with phone_number/created_at/updated_at/learning_source) AND
221
- // `pn:{pnUser}` (value=raw bytes of the LID user, UTF-8).
222
- // • Upstream (`Signal/lid-mapping.js`): keyed by bare `{pnUser}`
223
- // (value=string LID user) AND `{lidUser}_reverse` (value=string
224
- // PN user). No timestamps.
225
- // Translation goes both ways; metadata fields default to "now" /
226
- // "wrap-legacy-store" when reading from upstream-only state since the
227
- // JS side doesn't track them.
219
+ // identity: bridge holds 32-byte raw DJB pubkey, upstream holds 33 bytes
220
+ // (0x05 || 32-byte) the curve25519-XEdDSA wire form.
221
+ identity: {
222
+ toBridge(_key, value) {
223
+ const buf = toBuf(value);
224
+ if (!buf)
225
+ return null;
226
+ return buf.length === 33 && buf[0] === 0x05 ? new Uint8Array(buf.slice(1)) : buf;
227
+ },
228
+ fromBridge(_key, value) {
229
+ const v = Buffer.isBuffer(value) ? value : Buffer.from(value);
230
+ return v.length === 32 ? Buffer.concat([Buffer.from([0x05]), v]) : Buffer.from(v);
231
+ }
232
+ },
233
+ // lid_mapping: bridge uses prefixed keys (`lid:{X}` JSON entry,
234
+ // `pn:{X}` raw LID bytes); upstream uses bare keys (`{pnUser}` string LID,
235
+ // `{lidUser}_reverse` string PN). No upstream timestamps; we synthesise.
228
236
  lid_mapping: {
229
237
  toBridge(key, value) {
230
- // `value` here is whatever upstream stored: a string
231
- // (`pnUser→lidUser` or `lidUser_reverse→pnUser`) — useMultiFile's
232
- // BufferJSON.reviver leaves strings as strings.
233
238
  try {
234
- if (key.startsWith('pn:')) {
235
- // Bridge wants the raw LID user as bytes.
236
- const lid = typeof value === 'string' ? value : Buffer.from(value).toString('utf-8');
237
- if (!lid)
238
- return null;
239
- return new TextEncoder().encode(lid);
240
- }
239
+ const asString = typeof value === 'string' ? value : Buffer.from(value).toString('utf-8');
240
+ if (!asString)
241
+ return null;
242
+ if (key.startsWith('pn:'))
243
+ return new TextEncoder().encode(asString);
241
244
  if (key.startsWith('lid:')) {
242
- // Bridge wants the JSON LidPnMappingEntry. Upstream stored
243
- // only the phone number string; reconstruct a synthetic
244
- // entry — bridge tolerates synthetic timestamps + source.
245
- const lidUser = key.slice(4);
246
- const phoneNumber = typeof value === 'string' ? value : Buffer.from(value).toString('utf-8');
247
- if (!phoneNumber)
248
- return null;
249
245
  const now = Math.floor(Date.now() / 1000);
250
246
  return toJson({
251
- lid: lidUser,
252
- phone_number: phoneNumber,
247
+ lid: key.slice(4),
248
+ phone_number: asString,
253
249
  created_at: now,
254
250
  updated_at: now,
255
251
  learning_source: 'wrap-legacy-store'
@@ -264,18 +260,10 @@ const converters = {
264
260
  }
265
261
  },
266
262
  fromBridge(key, value) {
267
- // `value` here is bridge's Uint8Array. We return a string for
268
- // upstream to store — useMultiFile's BufferJSON.replacer keeps
269
- // strings as strings, which is what `lid-mapping.js` expects to
270
- // read back via `keys.get('lid-mapping', […])`.
271
263
  try {
272
- if (key.startsWith('pn:')) {
273
- // Bridge wrote raw LID bytes. Upstream wants the string.
264
+ if (key.startsWith('pn:'))
274
265
  return Buffer.from(value).toString('utf-8');
275
- }
276
266
  if (key.startsWith('lid:')) {
277
- // Bridge wrote JSON entry. Upstream wants the phone_number
278
- // string under `{lidUser}_reverse`.
279
267
  const entry = fromJson(value);
280
268
  return entry.phone_number ?? '';
281
269
  }
@@ -288,26 +276,16 @@ const converters = {
288
276
  }
289
277
  }
290
278
  },
291
- // ── session ──
292
- // Cross-impl converter for pairwise Signal sessions.
293
- // Rust libsignal (bridge): raw protobuf `RecordStructure` bytes
294
- // (current_session + previous_sessions, each `SessionStructure`).
295
- // JS libsignal (upstream): a plain JS object
296
- // `{ _sessions: { [base64BaseKey]: SessionEntry }, version: 'v1' }`
297
- // persisted by upstream's `useMultiFileAuthState` via
298
- // JSON.stringify-with-BufferJSON. The byte fields inside SessionEntry
299
- // are stored as base64 STRINGS (not BufferJSON Buffer wrappers).
300
- // We translate at the boundary so the same auth folder works for both.
301
- // Note: per-message-counter caches (`messageKeys`) are dropped during
302
- // conversion — the two implementations derive them differently (Rust
303
- // stores cipher/mac/iv post-split; JS stores the pre-split chain output)
304
- // and they're a CACHE, not state — protocol re-derives or asks for retry.
279
+ // session: bridge=protobuf RecordStructure, upstream=JS object
280
+ // `{_sessions: {[b64BaseKey]: SessionEntry}, version: 'v1'}` with byte
281
+ // fields stored as base64 strings inside the entry. messageKeys cache
282
+ // is lossy in the Rust→JS direction (HKDF is one-way) see
283
+ // `bridgeSessionProtoToUpstreamRecord`.
305
284
  session: {
306
285
  toBridge(_key, value) {
307
- // Upstream stored: a plain JS object (NOT bytes). Convert → proto bytes.
308
286
  try {
309
287
  if (value == null)
310
- return null; // eslint-disable-line eqeqeq -- intentional null+undefined check
288
+ return null; // eslint-disable-line eqeqeq
311
289
  return upstreamSessionRecordToProto(value);
312
290
  }
313
291
  catch (e) {
@@ -316,7 +294,6 @@ const converters = {
316
294
  }
317
295
  },
318
296
  fromBridge(_key, value) {
319
- // Bridge wrote: raw proto bytes. Convert → upstream JS object.
320
297
  try {
321
298
  return bridgeSessionProtoToUpstreamRecord(value);
322
299
  }
@@ -326,19 +303,11 @@ const converters = {
326
303
  }
327
304
  }
328
305
  },
329
- // ── sender_key ──
330
- // Cross-impl converter for group sender keys. Both libraries produce
331
- // the same logical record (`SenderKeyRecordStructure`) but persist it
332
- // under DIFFERENT bytes:
333
- // • Rust libsignal (bridge): raw protobuf bytes
334
- // • JS libsignal (upstream): UTF-8 JSON of the record array, with
335
- // each Buffer wrapped as `{ type:'Buffer', data:'<base64>' }`
336
- // (BufferJSON.replacer in upstream's `Utils/generics`).
337
- // We translate at the boundary so the same on-disk file is readable
338
- // by either implementation — the entire point of `wrap-legacy-store`.
306
+ // sender_key: bridge=protobuf SenderKeyRecordStructure, upstream=
307
+ // `Buffer.from(JSON.stringify(states, BufferJSON.replacer), 'utf-8')`
308
+ // Buffer fields wrapped as `{type:'Buffer', data:'<base64>'}`.
339
309
  sender_key: {
340
310
  toBridge(_key, value) {
341
- // Upstream stored: Buffer of UTF-8 JSON. Convert → proto bytes.
342
311
  try {
343
312
  const buf = toBuf(value);
344
313
  if (!buf)
@@ -351,84 +320,69 @@ const converters = {
351
320
  }
352
321
  },
353
322
  fromBridge(_key, value) {
354
- // Bridge wrote: raw proto bytes. Convert → upstream JSON Buffer.
355
323
  try {
356
324
  return bridgeSenderKeyProtoToJson(value);
357
325
  }
358
326
  catch (e) {
359
327
  warn('sender_key.fromBridge encode failed:', e);
360
- // Fall back to passthrough so we don't drop data on the floor.
361
328
  return Buffer.from(value);
362
329
  }
363
330
  }
364
331
  }
365
332
  };
366
- const bufferReplacerForSenderKey = (_k, v) => {
333
+ // BufferJSON-compatible replacer/reviver matching upstream `Utils/generics`.
334
+ const bufferJsonReplacer = (_k, v) => {
367
335
  if (Buffer.isBuffer(v) || v instanceof Uint8Array || v?.type === 'Buffer') {
368
336
  const data = v?.data ?? v;
369
337
  return { type: 'Buffer', data: Buffer.from(data).toString('base64') };
370
338
  }
371
339
  return v;
372
340
  };
373
- const bufferReviverForSenderKey = (_k, v) => {
374
- if (typeof v === 'object' && v !== null) {
375
- const obj = v;
376
- if (obj.type === 'Buffer' && typeof obj.data === 'string') {
377
- return Buffer.from(obj.data, 'base64');
378
- }
341
+ const bufferJsonReviver = (_k, v) => {
342
+ const obj = v;
343
+ if (obj && typeof obj === 'object' && obj.type === 'Buffer' && typeof obj.data === 'string') {
344
+ return Buffer.from(obj.data, 'base64');
379
345
  }
380
346
  return v;
381
347
  };
348
+ const sk_toBytes = (v) => v ? new Uint8Array(Buffer.from(v)) : new Uint8Array();
349
+ const sk_toBuffer = (v) => Buffer.from(v ?? new Uint8Array());
382
350
  function bridgeSenderKeyProtoToJson(protoBytes) {
383
351
  const struct = proto.SenderKeyRecordStructure.decode(Buffer.from(protoBytes));
384
352
  const states = (struct.senderKeyStates ?? []).map(s => ({
385
353
  senderKeyId: s.senderKeyId ?? 0,
386
- senderChainKey: {
387
- iteration: s.senderChainKey?.iteration ?? 0,
388
- seed: Buffer.from(s.senderChainKey?.seed ?? new Uint8Array())
389
- },
354
+ senderChainKey: { iteration: s.senderChainKey?.iteration ?? 0, seed: sk_toBuffer(s.senderChainKey?.seed) },
390
355
  senderSigningKey: {
391
- public: Buffer.from(s.senderSigningKey?.public ?? new Uint8Array()),
392
- private: Buffer.from(s.senderSigningKey?.private ?? new Uint8Array())
356
+ public: sk_toBuffer(s.senderSigningKey?.public),
357
+ private: sk_toBuffer(s.senderSigningKey?.private)
393
358
  },
394
359
  senderMessageKeys: (s.senderMessageKeys ?? []).map(mk => ({
395
360
  iteration: mk.iteration ?? 0,
396
- seed: Buffer.from(mk.seed ?? new Uint8Array())
361
+ seed: sk_toBuffer(mk.seed)
397
362
  }))
398
363
  }));
399
- return Buffer.from(JSON.stringify(states, bufferReplacerForSenderKey), 'utf-8');
364
+ return Buffer.from(JSON.stringify(states, bufferJsonReplacer), 'utf-8');
400
365
  }
401
366
  function upstreamSenderKeyJsonToProto(jsonBuf) {
402
- const text = Buffer.from(jsonBuf).toString('utf-8');
403
- const states = JSON.parse(text, bufferReviverForSenderKey);
367
+ const states = JSON.parse(Buffer.from(jsonBuf).toString('utf-8'), bufferJsonReviver);
404
368
  const senderKeyStates = states.map(s => ({
405
369
  senderKeyId: s.senderKeyId ?? 0,
406
- senderChainKey: {
407
- iteration: s.senderChainKey?.iteration ?? 0,
408
- seed: s.senderChainKey?.seed ? new Uint8Array(Buffer.from(s.senderChainKey.seed)) : new Uint8Array()
409
- },
370
+ senderChainKey: { iteration: s.senderChainKey?.iteration ?? 0, seed: sk_toBytes(s.senderChainKey?.seed) },
410
371
  senderSigningKey: {
411
- public: s.senderSigningKey?.public ? new Uint8Array(Buffer.from(s.senderSigningKey.public)) : new Uint8Array(),
412
- private: s.senderSigningKey?.private ? new Uint8Array(Buffer.from(s.senderSigningKey.private)) : new Uint8Array()
372
+ public: sk_toBytes(s.senderSigningKey?.public),
373
+ private: sk_toBytes(s.senderSigningKey?.private)
413
374
  },
414
375
  senderMessageKeys: (s.senderMessageKeys ?? []).map(mk => ({
415
376
  iteration: mk.iteration ?? 0,
416
- seed: mk.seed ? new Uint8Array(Buffer.from(mk.seed)) : new Uint8Array()
377
+ seed: sk_toBytes(mk.seed)
417
378
  }))
418
379
  }));
419
- const struct = proto.SenderKeyRecordStructure.create({ senderKeyStates });
420
- return proto.SenderKeyRecordStructure.encode(struct).finish();
380
+ return proto.SenderKeyRecordStructure.encode(proto.SenderKeyRecordStructure.create({ senderKeyStates })).finish();
421
381
  }
422
- // ── sender_key key translator ──
423
- // Bridge `cache_key`: `{groupJid}:{user}[:jidDev]@{server}.{signalDev}`
424
- // e.g. "120363@g.us:100000037037034@lid.0"
425
- // e.g. "120363@g.us:559980000003:5@s.whatsapp.net.0"
426
- // Upstream `SenderKeyName.serialize()`: `{groupJid}::{signalUser}::{deviceId}`
427
- // where signalUser = `${user}` for s.whatsapp.net (domainType 0)
428
- // = `${user}_${domainType}` for LID (1) / hosted (2) / hosted.lid (3)
382
+ // sender_key key translation: bridge `{group}:{user}[:dev]@{server}.{sig}`
383
+ // upstream `{group}::{signalUser}::{deviceId}`. Group JIDs never contain
384
+ // `:`, so the first `:` separates group from sender address.
429
385
  function bridgeSenderKeyToUpstream(bridgeKey) {
430
- // Group JIDs are `{digits}@g.us` — they never contain `:` — so the
431
- // FIRST `:` in cache_key cleanly separates group from sender address.
432
386
  const sep = bridgeKey.indexOf(':');
433
387
  if (sep < 0)
434
388
  return null;
@@ -437,28 +391,19 @@ function bridgeSenderKeyToUpstream(bridgeKey) {
437
391
  const dotIdx = addrStr.lastIndexOf('.');
438
392
  if (dotIdx < 0)
439
393
  return null;
440
- const jidPart = addrStr.slice(0, dotIdx); // strip the always-zero signalDev suffix
394
+ const jidPart = addrStr.slice(0, dotIdx);
441
395
  const atIdx = jidPart.indexOf('@');
442
396
  if (atIdx < 0)
443
397
  return null;
444
398
  const userPart = jidPart.slice(0, atIdx);
445
399
  const server = jidPart.slice(atIdx + 1);
446
- let user;
447
- let jidDev;
448
- if (userPart.includes(':')) {
449
- const [u, d] = userPart.split(':');
450
- user = u;
451
- jidDev = parseInt(d, 10) || 0;
452
- }
453
- else {
454
- user = userPart;
455
- jidDev = 0;
456
- }
400
+ const [user, jidDev] = userPart.includes(':')
401
+ ? [userPart.slice(0, userPart.indexOf(':')), parseInt(userPart.slice(userPart.indexOf(':') + 1), 10) || 0]
402
+ : [userPart, 0];
457
403
  const domainType = DOMAIN_TYPE_MAP[server];
458
404
  if (domainType === undefined)
459
405
  return null;
460
- const signalUser = domainType !== 0 ? `${user}_${domainType}` : user;
461
- return `${groupJid}::${signalUser}::${jidDev}`;
406
+ return `${groupJid}::${domainType !== 0 ? `${user}_${domainType}` : user}::${jidDev}`;
462
407
  }
463
408
  const CHAIN_TYPE_SENDING = 1;
464
409
  const CHAIN_TYPE_RECEIVING = 2;
@@ -467,46 +412,28 @@ const BASE_KEY_TYPE_THEIRS = 2;
467
412
  const b64 = (b) => b ? Buffer.from(b).toString('base64') : Buffer.alloc(0).toString('base64');
468
413
  const fromB64 = (s) => (s ? Buffer.from(s, 'base64') : Buffer.alloc(0));
469
414
  /**
470
- * Derive Rust libsignal's per-message split (cipher_key + mac_key + iv) from
471
- * the JS libsignal `messageKey` seed. The two impls store the cache at
472
- * different points in the derivation:
473
- *
474
- * JS: stores the 32-byte HMAC(chainKey, [1]) seed; splits on decrypt
475
- * via `deriveSecrets(seed, ZEROS_32, "WhisperMessageKeys")`.
476
- * • Rust: stores the post-split `MessageKeys { cipher_key:[u8;32],
477
- * mac_key:[u8;32], iv:[u8;16] }` directly.
478
- *
479
- * Both call HKDF-SHA256 with salt=[0u8;32] and info="WhisperMessageKeys"
480
- * (Rust passes `None` salt which HKDF defines as a zero-byte string of
481
- * HashLen=32), so the derivation is byte-identical. We can recover Rust's
482
- * format from JS's seed losslessly. The reverse (Rust → JS) is impossible
483
- * because HKDF is one-way — those keys are dropped on Rust→JS conversion.
415
+ * Derive Rust's per-message split (cipher 32 / mac 32 / iv 16) from JS's
416
+ * 32-byte `messageKey` seed. Both impls compute HKDF-SHA256 with
417
+ * salt=[0u8;32] and info="WhisperMessageKeys" Rust's `None` salt
418
+ * defaults to a zero-byte HashLen string, so the output is byte-identical.
419
+ * Reverse direction (Rust→JS) is impossible: HKDF is one-way.
484
420
  */
485
421
  function deriveProtoMessageKey(seed, counter) {
486
- // HKDF-Extract: PRK = HMAC-SHA256(salt=ZEROS_32, ikm=seed)
487
- const salt = Buffer.alloc(32);
488
- const prk = createHmac('sha256', salt).update(seed).digest();
422
+ const prk = createHmac('sha256', Buffer.alloc(32)).update(seed).digest();
489
423
  const info = Buffer.from('WhisperMessageKeys');
490
- // HKDF-Expand: T(1)=HMAC(PRK, info||0x01); T(N)=HMAC(PRK, T(N-1)||info||N).
491
- // We need 80 bytes total (cipher 32 + mac 32 + iv 16) → 3 chunks.
492
- const t1 = createHmac('sha256', prk)
493
- .update(Buffer.concat([info, Buffer.from([0x01])]))
494
- .digest();
495
- const t2 = createHmac('sha256', prk)
496
- .update(Buffer.concat([t1, info, Buffer.from([0x02])]))
497
- .digest();
498
- const t3 = createHmac('sha256', prk)
499
- .update(Buffer.concat([t2, info, Buffer.from([0x03])]))
424
+ const expand = (prev, n) => createHmac('sha256', prk)
425
+ .update(Buffer.concat([prev, info, Buffer.from([n])]))
500
426
  .digest();
427
+ const t1 = expand(Buffer.alloc(0), 0x01);
428
+ const t2 = expand(t1, 0x02);
429
+ const t3 = expand(t2, 0x03);
501
430
  return {
502
431
  index: counter,
503
- cipherKey: new Uint8Array(t1), // 32
504
- macKey: new Uint8Array(t2), // 32
505
- iv: new Uint8Array(t3.subarray(0, 16)) // first 16 of T(3)
432
+ cipherKey: new Uint8Array(t1),
433
+ macKey: new Uint8Array(t2),
434
+ iv: new Uint8Array(t3.subarray(0, 16))
506
435
  };
507
436
  }
508
- /** Convert all entries in a JS chain's messageKeys cache into Rust's
509
- * post-split `MessageKey` proto form. Empty/missing → empty array. */
510
437
  function jsChainMessageKeysToProto(messageKeys) {
511
438
  if (!messageKeys)
512
439
  return [];
@@ -516,72 +443,49 @@ function jsChainMessageKeysToProto(messageKeys) {
516
443
  if (!Number.isFinite(counter))
517
444
  continue;
518
445
  const seed = fromB64(seedB64);
519
- if (seed.length === 0)
520
- continue;
521
- out.push(deriveProtoMessageKey(seed, counter));
446
+ if (seed.length > 0)
447
+ out.push(deriveProtoMessageKey(seed, counter));
522
448
  }
523
449
  return out;
524
450
  }
525
451
  function sessionStructureToEntry(session, closedTs) {
526
452
  if (!session.senderChain || !session.rootKey)
527
453
  return null;
528
- // `aliceBaseKey` is populated by Rust libsignal in BOTH directions of
529
- // a session: when we're Alice it stores our own ephemeral pubkey, when
530
- // we're Bob it stores Alice's ephemeral pubkey from the incoming
531
- // PreKeySignalMessage. So presence alone doesn't tell us which side
532
- // we are — we use `pendingPreKey` instead (set on Alice until the first
533
- // reply from Bob clears it; never set on Bob).
454
+ // aliceBaseKey is set by Rust on BOTH sides (alice=our ephemeral, bob=
455
+ // peer's ephemeral). pendingPreKey is the side discriminator (alice
456
+ // only, cleared on first reply).
534
457
  const baseKeyBytes = session.aliceBaseKey ?? session.senderChain.senderRatchetKey;
535
458
  if (!baseKeyBytes)
536
459
  return null;
537
460
  const weAreAlice = !!session.pendingPreKey?.baseKey;
538
461
  const baseKeyType = weAreAlice ? BASE_KEY_TYPE_OURS : BASE_KEY_TYPE_THEIRS;
539
- // lastRemoteEphemeralKey: the most recent peer ratchet step. Order of
540
- // preference:
541
- // 1. tail of `receiverChains` established session, peer has replied
542
- // 2. `aliceBaseKey` when we're Bob — peer's original ephemeral was
543
- // our `lastRemoteEphemeralKey` at session creation (matches JS
544
- // `session_builder.initSession` line 128)
545
- // 3. empty buffer — degenerate fallback. CANNOT use senderRatchetKey
546
- // as a placeholder: it collides with the SENDER chain in `_chains`,
547
- // and upstream's `maybeStepRatchet` would call
548
- // `delete previousRatchet.chainKey.key` on the sender chain when
549
- // the peer ratchets, corrupting outbound encryption.
462
+ // lastRemoteEphemeralKey precedence: tail receiverChain aliceBaseKey
463
+ // (when bob, matches JS init) → empty. NEVER senderRatchetKey: that
464
+ // collides with the SENDER chain in `_chains`, and upstream's
465
+ // `maybeStepRatchet` would `delete previousRatchet.chainKey.key` on
466
+ // the sender chain when the peer ratchets, corrupting outbound encryption.
550
467
  const receiverChains = session.receiverChains ?? [];
551
468
  const lastReceiver = receiverChains[receiverChains.length - 1];
552
- let lastRemoteEph;
553
- if (lastReceiver?.senderRatchetKey && lastReceiver.senderRatchetKey.length > 0) {
554
- lastRemoteEph = lastReceiver.senderRatchetKey;
555
- }
556
- else if (!weAreAlice && session.aliceBaseKey && session.aliceBaseKey.length > 0) {
557
- lastRemoteEph = session.aliceBaseKey;
558
- }
559
- else {
560
- lastRemoteEph = new Uint8Array();
561
- }
469
+ const lastRemoteEph = lastReceiver?.senderRatchetKey && lastReceiver.senderRatchetKey.length > 0
470
+ ? lastReceiver.senderRatchetKey
471
+ : !weAreAlice && session.aliceBaseKey && session.aliceBaseKey.length > 0
472
+ ? session.aliceBaseKey
473
+ : new Uint8Array();
562
474
  const senderRatchetPub = session.senderChain.senderRatchetKey ?? new Uint8Array();
563
475
  const senderRatchetPriv = session.senderChain.senderRatchetKeyPrivate ?? new Uint8Array();
564
476
  const _chains = {};
565
- // Sender chain: keyed by our own ratchet pubkey base64.
566
477
  if (session.senderChain.chainKey?.key && senderRatchetPub.length > 0) {
567
478
  _chains[b64(senderRatchetPub)] = {
568
- chainKey: {
569
- counter: session.senderChain.chainKey.index ?? 0,
570
- key: b64(session.senderChain.chainKey.key)
571
- },
479
+ chainKey: { counter: session.senderChain.chainKey.index ?? 0, key: b64(session.senderChain.chainKey.key) },
572
480
  chainType: CHAIN_TYPE_SENDING,
573
481
  messageKeys: {}
574
482
  };
575
483
  }
576
- // Receiver chains: keyed by peer ratchet pubkey base64.
577
484
  for (const rc of receiverChains) {
578
485
  if (!rc.chainKey?.key || !rc.senderRatchetKey || rc.senderRatchetKey.length === 0)
579
486
  continue;
580
487
  _chains[b64(rc.senderRatchetKey)] = {
581
- chainKey: {
582
- counter: rc.chainKey.index ?? 0,
583
- key: b64(rc.chainKey.key)
584
- },
488
+ chainKey: { counter: rc.chainKey.index ?? 0, key: b64(rc.chainKey.key) },
585
489
  chainType: CHAIN_TYPE_RECEIVING,
586
490
  messageKeys: {}
587
491
  };
@@ -620,16 +524,12 @@ function bridgeSessionProtoToUpstreamRecord(protoBytes) {
620
524
  const current = record.currentSession ? sessionStructureToEntry(record.currentSession, -1) : null;
621
525
  if (current)
622
526
  _sessions[current.indexInfo.baseKey] = current;
623
- // Closed sessions: assign descending timestamps so removeOldSessions
624
- // has something to sort by. Use `1` as the floor (anything > 0 is
625
- // "closed"; -1 is "open").
527
+ // Synthesize descending close timestamps so removeOldSessions can sort:
528
+ // front of Rust `previous_sessions` is the most recently archived.
626
529
  const previous = record.previousSessions ?? [];
627
530
  for (let i = 0; i < previous.length; i++) {
628
531
  const entry = sessionStructureToEntry(previous[i], Math.max(1, Date.now() - i));
629
- if (!entry)
630
- continue;
631
- // Don't overwrite an existing baseKey (current takes precedence).
632
- if (!_sessions[entry.indexInfo.baseKey])
532
+ if (entry && !_sessions[entry.indexInfo.baseKey])
633
533
  _sessions[entry.indexInfo.baseKey] = entry;
634
534
  }
635
535
  return { _sessions, version: 'v1' };
@@ -646,25 +546,23 @@ function entryToSessionStructure(entry) {
646
546
  chainKey: senderChainEntry
647
547
  ? { index: senderChainEntry.chainKey.counter, key: new Uint8Array(fromB64(senderChainEntry.chainKey.key)) }
648
548
  : { index: 0, key: new Uint8Array() },
649
- // Sender chain has no skipped-key cache in either impl — sending
650
- // is sequential — so this stays empty regardless.
651
- messageKeys: []
549
+ messageKeys: [] // sender chain is sequential — no skipped cache either side
652
550
  };
653
551
  const receiverChains = [];
654
552
  for (const [k, ch] of Object.entries(entry._chains)) {
655
553
  if (ch.chainType !== CHAIN_TYPE_RECEIVING)
656
554
  continue;
657
- const ratchetPub = fromB64(k);
658
555
  receiverChains.push({
659
- senderRatchetKey: new Uint8Array(ratchetPub),
556
+ senderRatchetKey: new Uint8Array(fromB64(k)),
660
557
  chainKey: { index: ch.chainKey.counter, key: new Uint8Array(fromB64(ch.chainKey.key)) },
661
- // Recover skipped-key cache: JS holds 32-byte HMAC seeds, Rust
662
- // holds the post-HKDF cipher/mac/iv split. HKDF is deterministic,
663
- // so JS→Rust is a lossless one-shot derivation. See
664
- // `deriveProtoMessageKey` for the algo + cross-impl proof.
558
+ // JS holds 32-byte HMAC seeds; Rust holds the post-HKDF split.
559
+ // Lossless JS→Rust derivation via deriveProtoMessageKey.
665
560
  messageKeys: jsChainMessageKeysToProto(ch.messageKeys)
666
561
  });
667
562
  }
563
+ // `indexInfo.baseKey` IS the alice_base_key in either direction
564
+ // (alice=our ephemeral, bob=peer's ephemeral). Rust populates it
565
+ // symmetrically; dropping it would break find_matching_previous_session_index.
668
566
  const session = {
669
567
  rootKey: new Uint8Array(fromB64(ratchet.rootKey)),
670
568
  previousCounter: ratchet.previousCounter,
@@ -672,14 +570,6 @@ function entryToSessionStructure(entry) {
672
570
  receiverChains,
673
571
  remoteIdentityPublic: new Uint8Array(fromB64(entry.indexInfo.remoteIdentityKey)),
674
572
  remoteRegistrationId: entry.registrationId ?? 0,
675
- // `indexInfo.baseKey` IS the alice base key from the X3DH handshake
676
- // regardless of which side we are: when we're Alice it's our own
677
- // ephemeral pubkey, when we're Bob it's the peer's ephemeral pubkey
678
- // from the incoming PreKeySignalMessage (`session_builder.js:135`).
679
- // Rust libsignal populates `aliceBaseKey` symmetrically, so we
680
- // always restore it — losing it would break Rust's
681
- // `find_matching_previous_session_index` lookup on subsequent
682
- // PreKeySignal arrivals.
683
573
  aliceBaseKey: new Uint8Array(fromB64(entry.indexInfo.baseKey))
684
574
  };
685
575
  if (entry.pendingPreKey?.baseKey) {
@@ -692,21 +582,15 @@ function entryToSessionStructure(entry) {
692
582
  return session;
693
583
  }
694
584
  function upstreamSessionRecordToProto(record) {
695
- const r = record;
696
- const sessions = r?._sessions ?? {};
585
+ const sessions = record?._sessions ?? {};
697
586
  let currentSession = null;
698
587
  const previousSessions = [];
699
- // Pick the first OPEN session as `current_session`; everything else
700
- // goes to `previous_sessions`. Both libraries should agree there is
701
- // at most one open session per peer.
702
588
  for (const entry of Object.values(sessions)) {
703
589
  const ss = entryToSessionStructure(entry);
704
- if (!currentSession && entry.indexInfo.closed === -1) {
590
+ if (!currentSession && entry.indexInfo.closed === -1)
705
591
  currentSession = ss;
706
- }
707
- else {
592
+ else
708
593
  previousSessions.push(ss);
709
- }
710
594
  }
711
595
  // `BridgeSessionProto` is a structural mirror of `ISessionStructure`
712
596
  // from `whatsapp-rust-bridge/proto-types`; the differences are nullable
@@ -720,14 +604,8 @@ function upstreamSessionRecordToProto(record) {
720
604
  });
721
605
  return proto.RecordStructure.encode(recordOut).finish();
722
606
  }
723
- // ── session key translator ──
724
- // Bridge protocol address: `{user}[:jidDev]@{server}.{signalDev}`
725
- // Upstream session key: `{signalUser}.{deviceId}` (where deviceId is
726
- // the JID device for `:jidDev` cases, else the signal device suffix).
727
- // `bridgeAddrToBaileysAddr` already does this exact transformation —
728
- // it's just been unused for the session path because sessions were
729
- // passthrough binary. Now that sessions go through a converter, we
730
- // route through `translateKey` → `bridgeAddrToBaileysAddr`.
607
+ // session/identity key translation: bridge `{user}[:dev]@{server}.{sig}`
608
+ // upstream `{signalUser}.{deviceId}` via `bridgeAddrToBaileysAddr`.
731
609
  // ---- Device/Creds ----
732
610
  /** Parse a JID string into { user, device, server } */
733
611
  function parseJid(jid) {
@@ -1033,15 +911,15 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1033
911
  }
1034
912
  return translated;
1035
913
  }
1036
- // Bridge session key is the protocol address (`user[:dev]@server.sig`);
1037
- // upstream's libsignal looks under `signalUser.deviceId`. Reuses
1038
- // `bridgeAddrToBaileysAddr` which already does the LID/PN→signalUser
1039
- // rewrite + JID-device extraction.
1040
- if (storeName === 'session') {
914
+ // Bridge session/identity keys are the protocol address
915
+ // (`user[:dev]@server.sig`); upstream's libsignal looks under
916
+ // `signalUser.deviceId`. `bridgeAddrToBaileysAddr` does the LID/PN
917
+ // → signalUser rewrite + JID-device extraction.
918
+ if (storeName === 'session' || storeName === 'identity') {
1041
919
  const translated = bridgeAddrToBaileysAddr(key);
1042
920
  if (translated == null) {
1043
921
  // eslint-disable-line eqeqeq -- intentional null+undefined check
1044
- warn(`session key translation failed for "${key}", falling back to passthrough`);
922
+ warn(`${storeName} key translation failed for "${key}", falling back to passthrough`);
1045
923
  return key;
1046
924
  }
1047
925
  return translated;
@@ -1114,13 +992,12 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1114
992
  return readBinary(type, key);
1115
993
  }
1116
994
  if (route === 'signal') {
1117
- const val = await readBinary(type, key);
1118
- if (val)
1119
- return val;
1120
- // Identity fallback: upstream Baileys uses a different address format
1121
- if (bridgeStore === 'identity')
1122
- return identityFallback(key, type);
1123
- return null;
995
+ // `BINARY_STORES` is empty as of the move-to-converter
996
+ // migration — keeping the branch for future passthrough
997
+ // stores. No identity fallback needed: identity now goes
998
+ // through the converter, which handles both the address
999
+ // rewrite and the 32↔33-byte prefix.
1000
+ return readBinary(type, key);
1124
1001
  }
1125
1002
  if (route === 'bridge-only')
1126
1003
  return readBinary(type, key);
@@ -1202,28 +1079,6 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1202
1079
  }
1203
1080
  }
1204
1081
  };
1205
- // Identity key fallback: upstream Baileys uses different address format
1206
- // (user_domainType.device) and stores 33 bytes (0x05 + 32-byte DJB key)
1207
- async function identityFallback(key, type) {
1208
- try {
1209
- const baileysAddr = bridgeAddrToBaileysAddr(key);
1210
- if (!baileysAddr)
1211
- return null;
1212
- const raw = await storeGetOne('identity-key', baileysAddr);
1213
- if (!raw || (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)))
1214
- return null;
1215
- let buf = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
1216
- if (buf.length === 33 && buf[0] === 0x05)
1217
- buf = buf.slice(1);
1218
- const arr = new Uint8Array(buf);
1219
- await writeBinary(type, key, arr); // persist under bridge key for future reads
1220
- return arr;
1221
- }
1222
- catch (e) {
1223
- warn(`identity fallback failed for ${key}:`, e);
1224
- return null;
1225
- }
1226
- }
1227
1082
  }
1228
1083
  const bufferReviver = (_, value) => {
1229
1084
  if (value !== null &&