@oxidezap/baileyrs 0.0.8 → 0.0.10

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 (26) hide show
  1. package/lib/Bridge/__tests__/dts-drift.test.js +1 -3
  2. package/lib/Bridge/__tests__/dts-drift.test.js.map +1 -1
  3. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts +73 -0
  4. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts.map +1 -0
  5. package/lib/Utils/__tests__/_legacy-store-fixtures.js +113 -0
  6. package/lib/Utils/__tests__/_legacy-store-fixtures.js.map +1 -0
  7. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts +10 -0
  8. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts.map +1 -0
  9. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js +291 -0
  10. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js.map +1 -0
  11. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts +4 -19
  12. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts.map +1 -1
  13. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js +31 -97
  14. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js.map +1 -1
  15. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts +6 -27
  16. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts.map +1 -1
  17. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js +49 -177
  18. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js.map +1 -1
  19. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts +10 -28
  20. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts.map +1 -1
  21. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js +116 -299
  22. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js.map +1 -1
  23. package/lib/Utils/wrap-legacy-store.d.ts.map +1 -1
  24. package/lib/Utils/wrap-legacy-store.js +142 -289
  25. package/lib/Utils/wrap-legacy-store.js.map +1 -1
  26. 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
@@ -175,18 +177,24 @@ const converters = {
175
177
  },
176
178
  tc_token: {
177
179
  toBridge(_key, value) {
180
+ // Rust `TcTokenEntry { token: Vec<u8>, token_timestamp: i64,
181
+ // sender_timestamp: Option<i64> }`. serde_json maps Vec<u8> to a
182
+ // numeric array — base64 strings make the deserializer choke
183
+ // with "invalid type: string ... expected a sequence".
178
184
  const tc = value;
179
185
  return toJson({
180
- token: tc.token ? Buffer.from(tc.token).toString('base64') : '',
181
- token_timestamp: tc.timestamp ? parseInt(tc.timestamp) : 0
186
+ token: tc.token ? bufToNumArray(Buffer.from(tc.token)) : [],
187
+ token_timestamp: tc.timestamp ? parseInt(tc.timestamp) : 0,
188
+ sender_timestamp: tc.senderTimestamp ? parseInt(tc.senderTimestamp) : null
182
189
  });
183
190
  },
184
191
  fromBridge(_key, value) {
185
192
  try {
186
193
  const j = fromJson(value);
187
194
  return {
188
- token: j.token ? Buffer.from(j.token, 'base64') : Buffer.alloc(0),
189
- timestamp: j.token_timestamp?.toString()
195
+ token: Array.isArray(j.token) ? Buffer.from(j.token) : Buffer.alloc(0),
196
+ timestamp: j.token_timestamp?.toString(),
197
+ senderTimestamp: j.sender_timestamp != null ? j.sender_timestamp.toString() : undefined
190
198
  };
191
199
  }
192
200
  catch {
@@ -214,42 +222,36 @@ const converters = {
214
222
  }
215
223
  }
216
224
  },
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.
225
+ // identity: bridge holds 32-byte raw DJB pubkey, upstream holds 33 bytes
226
+ // (0x05 || 32-byte) the curve25519-XEdDSA wire form.
227
+ identity: {
228
+ toBridge(_key, value) {
229
+ const buf = toBuf(value);
230
+ if (!buf)
231
+ return null;
232
+ return buf.length === 33 && buf[0] === 0x05 ? new Uint8Array(buf.slice(1)) : buf;
233
+ },
234
+ fromBridge(_key, value) {
235
+ const v = Buffer.isBuffer(value) ? value : Buffer.from(value);
236
+ return v.length === 32 ? Buffer.concat([Buffer.from([0x05]), v]) : Buffer.from(v);
237
+ }
238
+ },
239
+ // lid_mapping: bridge uses prefixed keys (`lid:{X}` JSON entry,
240
+ // `pn:{X}` raw LID bytes); upstream uses bare keys (`{pnUser}` string LID,
241
+ // `{lidUser}_reverse` string PN). No upstream timestamps; we synthesise.
228
242
  lid_mapping: {
229
243
  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
244
  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
- }
245
+ const asString = typeof value === 'string' ? value : Buffer.from(value).toString('utf-8');
246
+ if (!asString)
247
+ return null;
248
+ if (key.startsWith('pn:'))
249
+ return new TextEncoder().encode(asString);
241
250
  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
251
  const now = Math.floor(Date.now() / 1000);
250
252
  return toJson({
251
- lid: lidUser,
252
- phone_number: phoneNumber,
253
+ lid: key.slice(4),
254
+ phone_number: asString,
253
255
  created_at: now,
254
256
  updated_at: now,
255
257
  learning_source: 'wrap-legacy-store'
@@ -264,18 +266,10 @@ const converters = {
264
266
  }
265
267
  },
266
268
  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
269
  try {
272
- if (key.startsWith('pn:')) {
273
- // Bridge wrote raw LID bytes. Upstream wants the string.
270
+ if (key.startsWith('pn:'))
274
271
  return Buffer.from(value).toString('utf-8');
275
- }
276
272
  if (key.startsWith('lid:')) {
277
- // Bridge wrote JSON entry. Upstream wants the phone_number
278
- // string under `{lidUser}_reverse`.
279
273
  const entry = fromJson(value);
280
274
  return entry.phone_number ?? '';
281
275
  }
@@ -288,26 +282,16 @@ const converters = {
288
282
  }
289
283
  }
290
284
  },
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.
285
+ // session: bridge=protobuf RecordStructure, upstream=JS object
286
+ // `{_sessions: {[b64BaseKey]: SessionEntry}, version: 'v1'}` with byte
287
+ // fields stored as base64 strings inside the entry. messageKeys cache
288
+ // is lossy in the Rust→JS direction (HKDF is one-way) see
289
+ // `bridgeSessionProtoToUpstreamRecord`.
305
290
  session: {
306
291
  toBridge(_key, value) {
307
- // Upstream stored: a plain JS object (NOT bytes). Convert → proto bytes.
308
292
  try {
309
293
  if (value == null)
310
- return null; // eslint-disable-line eqeqeq -- intentional null+undefined check
294
+ return null;
311
295
  return upstreamSessionRecordToProto(value);
312
296
  }
313
297
  catch (e) {
@@ -316,7 +300,6 @@ const converters = {
316
300
  }
317
301
  },
318
302
  fromBridge(_key, value) {
319
- // Bridge wrote: raw proto bytes. Convert → upstream JS object.
320
303
  try {
321
304
  return bridgeSessionProtoToUpstreamRecord(value);
322
305
  }
@@ -326,19 +309,11 @@ const converters = {
326
309
  }
327
310
  }
328
311
  },
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`.
312
+ // sender_key: bridge=protobuf SenderKeyRecordStructure, upstream=
313
+ // `Buffer.from(JSON.stringify(states, BufferJSON.replacer), 'utf-8')`
314
+ // Buffer fields wrapped as `{type:'Buffer', data:'<base64>'}`.
339
315
  sender_key: {
340
316
  toBridge(_key, value) {
341
- // Upstream stored: Buffer of UTF-8 JSON. Convert → proto bytes.
342
317
  try {
343
318
  const buf = toBuf(value);
344
319
  if (!buf)
@@ -351,84 +326,69 @@ const converters = {
351
326
  }
352
327
  },
353
328
  fromBridge(_key, value) {
354
- // Bridge wrote: raw proto bytes. Convert → upstream JSON Buffer.
355
329
  try {
356
330
  return bridgeSenderKeyProtoToJson(value);
357
331
  }
358
332
  catch (e) {
359
333
  warn('sender_key.fromBridge encode failed:', e);
360
- // Fall back to passthrough so we don't drop data on the floor.
361
334
  return Buffer.from(value);
362
335
  }
363
336
  }
364
337
  }
365
338
  };
366
- const bufferReplacerForSenderKey = (_k, v) => {
339
+ // BufferJSON-compatible replacer/reviver matching upstream `Utils/generics`.
340
+ const bufferJsonReplacer = (_k, v) => {
367
341
  if (Buffer.isBuffer(v) || v instanceof Uint8Array || v?.type === 'Buffer') {
368
342
  const data = v?.data ?? v;
369
343
  return { type: 'Buffer', data: Buffer.from(data).toString('base64') };
370
344
  }
371
345
  return v;
372
346
  };
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
- }
347
+ const bufferJsonReviver = (_k, v) => {
348
+ const obj = v;
349
+ if (obj && typeof obj === 'object' && obj.type === 'Buffer' && typeof obj.data === 'string') {
350
+ return Buffer.from(obj.data, 'base64');
379
351
  }
380
352
  return v;
381
353
  };
354
+ const sk_toBytes = (v) => v ? new Uint8Array(Buffer.from(v)) : new Uint8Array();
355
+ const sk_toBuffer = (v) => Buffer.from(v ?? new Uint8Array());
382
356
  function bridgeSenderKeyProtoToJson(protoBytes) {
383
357
  const struct = proto.SenderKeyRecordStructure.decode(Buffer.from(protoBytes));
384
358
  const states = (struct.senderKeyStates ?? []).map(s => ({
385
359
  senderKeyId: s.senderKeyId ?? 0,
386
- senderChainKey: {
387
- iteration: s.senderChainKey?.iteration ?? 0,
388
- seed: Buffer.from(s.senderChainKey?.seed ?? new Uint8Array())
389
- },
360
+ senderChainKey: { iteration: s.senderChainKey?.iteration ?? 0, seed: sk_toBuffer(s.senderChainKey?.seed) },
390
361
  senderSigningKey: {
391
- public: Buffer.from(s.senderSigningKey?.public ?? new Uint8Array()),
392
- private: Buffer.from(s.senderSigningKey?.private ?? new Uint8Array())
362
+ public: sk_toBuffer(s.senderSigningKey?.public),
363
+ private: sk_toBuffer(s.senderSigningKey?.private)
393
364
  },
394
365
  senderMessageKeys: (s.senderMessageKeys ?? []).map(mk => ({
395
366
  iteration: mk.iteration ?? 0,
396
- seed: Buffer.from(mk.seed ?? new Uint8Array())
367
+ seed: sk_toBuffer(mk.seed)
397
368
  }))
398
369
  }));
399
- return Buffer.from(JSON.stringify(states, bufferReplacerForSenderKey), 'utf-8');
370
+ return Buffer.from(JSON.stringify(states, bufferJsonReplacer), 'utf-8');
400
371
  }
401
372
  function upstreamSenderKeyJsonToProto(jsonBuf) {
402
- const text = Buffer.from(jsonBuf).toString('utf-8');
403
- const states = JSON.parse(text, bufferReviverForSenderKey);
373
+ const states = JSON.parse(Buffer.from(jsonBuf).toString('utf-8'), bufferJsonReviver);
404
374
  const senderKeyStates = states.map(s => ({
405
375
  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
- },
376
+ senderChainKey: { iteration: s.senderChainKey?.iteration ?? 0, seed: sk_toBytes(s.senderChainKey?.seed) },
410
377
  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()
378
+ public: sk_toBytes(s.senderSigningKey?.public),
379
+ private: sk_toBytes(s.senderSigningKey?.private)
413
380
  },
414
381
  senderMessageKeys: (s.senderMessageKeys ?? []).map(mk => ({
415
382
  iteration: mk.iteration ?? 0,
416
- seed: mk.seed ? new Uint8Array(Buffer.from(mk.seed)) : new Uint8Array()
383
+ seed: sk_toBytes(mk.seed)
417
384
  }))
418
385
  }));
419
- const struct = proto.SenderKeyRecordStructure.create({ senderKeyStates });
420
- return proto.SenderKeyRecordStructure.encode(struct).finish();
386
+ return proto.SenderKeyRecordStructure.encode(proto.SenderKeyRecordStructure.create({ senderKeyStates })).finish();
421
387
  }
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)
388
+ // sender_key key translation: bridge `{group}:{user}[:dev]@{server}.{sig}`
389
+ // upstream `{group}::{signalUser}::{deviceId}`. Group JIDs never contain
390
+ // `:`, so the first `:` separates group from sender address.
429
391
  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
392
  const sep = bridgeKey.indexOf(':');
433
393
  if (sep < 0)
434
394
  return null;
@@ -437,28 +397,19 @@ function bridgeSenderKeyToUpstream(bridgeKey) {
437
397
  const dotIdx = addrStr.lastIndexOf('.');
438
398
  if (dotIdx < 0)
439
399
  return null;
440
- const jidPart = addrStr.slice(0, dotIdx); // strip the always-zero signalDev suffix
400
+ const jidPart = addrStr.slice(0, dotIdx);
441
401
  const atIdx = jidPart.indexOf('@');
442
402
  if (atIdx < 0)
443
403
  return null;
444
404
  const userPart = jidPart.slice(0, atIdx);
445
405
  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
- }
406
+ const [user, jidDev] = userPart.includes(':')
407
+ ? [userPart.slice(0, userPart.indexOf(':')), parseInt(userPart.slice(userPart.indexOf(':') + 1), 10) || 0]
408
+ : [userPart, 0];
457
409
  const domainType = DOMAIN_TYPE_MAP[server];
458
410
  if (domainType === undefined)
459
411
  return null;
460
- const signalUser = domainType !== 0 ? `${user}_${domainType}` : user;
461
- return `${groupJid}::${signalUser}::${jidDev}`;
412
+ return `${groupJid}::${domainType !== 0 ? `${user}_${domainType}` : user}::${jidDev}`;
462
413
  }
463
414
  const CHAIN_TYPE_SENDING = 1;
464
415
  const CHAIN_TYPE_RECEIVING = 2;
@@ -467,46 +418,28 @@ const BASE_KEY_TYPE_THEIRS = 2;
467
418
  const b64 = (b) => b ? Buffer.from(b).toString('base64') : Buffer.alloc(0).toString('base64');
468
419
  const fromB64 = (s) => (s ? Buffer.from(s, 'base64') : Buffer.alloc(0));
469
420
  /**
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.
421
+ * Derive Rust's per-message split (cipher 32 / mac 32 / iv 16) from JS's
422
+ * 32-byte `messageKey` seed. Both impls compute HKDF-SHA256 with
423
+ * salt=[0u8;32] and info="WhisperMessageKeys" Rust's `None` salt
424
+ * defaults to a zero-byte HashLen string, so the output is byte-identical.
425
+ * Reverse direction (Rust→JS) is impossible: HKDF is one-way.
484
426
  */
485
427
  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();
428
+ const prk = createHmac('sha256', Buffer.alloc(32)).update(seed).digest();
489
429
  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])]))
430
+ const expand = (prev, n) => createHmac('sha256', prk)
431
+ .update(Buffer.concat([prev, info, Buffer.from([n])]))
500
432
  .digest();
433
+ const t1 = expand(Buffer.alloc(0), 0x01);
434
+ const t2 = expand(t1, 0x02);
435
+ const t3 = expand(t2, 0x03);
501
436
  return {
502
437
  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)
438
+ cipherKey: new Uint8Array(t1),
439
+ macKey: new Uint8Array(t2),
440
+ iv: new Uint8Array(t3.subarray(0, 16))
506
441
  };
507
442
  }
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
443
  function jsChainMessageKeysToProto(messageKeys) {
511
444
  if (!messageKeys)
512
445
  return [];
@@ -516,72 +449,49 @@ function jsChainMessageKeysToProto(messageKeys) {
516
449
  if (!Number.isFinite(counter))
517
450
  continue;
518
451
  const seed = fromB64(seedB64);
519
- if (seed.length === 0)
520
- continue;
521
- out.push(deriveProtoMessageKey(seed, counter));
452
+ if (seed.length > 0)
453
+ out.push(deriveProtoMessageKey(seed, counter));
522
454
  }
523
455
  return out;
524
456
  }
525
457
  function sessionStructureToEntry(session, closedTs) {
526
458
  if (!session.senderChain || !session.rootKey)
527
459
  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).
460
+ // aliceBaseKey is set by Rust on BOTH sides (alice=our ephemeral, bob=
461
+ // peer's ephemeral). pendingPreKey is the side discriminator (alice
462
+ // only, cleared on first reply).
534
463
  const baseKeyBytes = session.aliceBaseKey ?? session.senderChain.senderRatchetKey;
535
464
  if (!baseKeyBytes)
536
465
  return null;
537
466
  const weAreAlice = !!session.pendingPreKey?.baseKey;
538
467
  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.
468
+ // lastRemoteEphemeralKey precedence: tail receiverChain aliceBaseKey
469
+ // (when bob, matches JS init) → empty. NEVER senderRatchetKey: that
470
+ // collides with the SENDER chain in `_chains`, and upstream's
471
+ // `maybeStepRatchet` would `delete previousRatchet.chainKey.key` on
472
+ // the sender chain when the peer ratchets, corrupting outbound encryption.
550
473
  const receiverChains = session.receiverChains ?? [];
551
474
  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
- }
475
+ const lastRemoteEph = lastReceiver?.senderRatchetKey && lastReceiver.senderRatchetKey.length > 0
476
+ ? lastReceiver.senderRatchetKey
477
+ : !weAreAlice && session.aliceBaseKey && session.aliceBaseKey.length > 0
478
+ ? session.aliceBaseKey
479
+ : new Uint8Array();
562
480
  const senderRatchetPub = session.senderChain.senderRatchetKey ?? new Uint8Array();
563
481
  const senderRatchetPriv = session.senderChain.senderRatchetKeyPrivate ?? new Uint8Array();
564
482
  const _chains = {};
565
- // Sender chain: keyed by our own ratchet pubkey base64.
566
483
  if (session.senderChain.chainKey?.key && senderRatchetPub.length > 0) {
567
484
  _chains[b64(senderRatchetPub)] = {
568
- chainKey: {
569
- counter: session.senderChain.chainKey.index ?? 0,
570
- key: b64(session.senderChain.chainKey.key)
571
- },
485
+ chainKey: { counter: session.senderChain.chainKey.index ?? 0, key: b64(session.senderChain.chainKey.key) },
572
486
  chainType: CHAIN_TYPE_SENDING,
573
487
  messageKeys: {}
574
488
  };
575
489
  }
576
- // Receiver chains: keyed by peer ratchet pubkey base64.
577
490
  for (const rc of receiverChains) {
578
491
  if (!rc.chainKey?.key || !rc.senderRatchetKey || rc.senderRatchetKey.length === 0)
579
492
  continue;
580
493
  _chains[b64(rc.senderRatchetKey)] = {
581
- chainKey: {
582
- counter: rc.chainKey.index ?? 0,
583
- key: b64(rc.chainKey.key)
584
- },
494
+ chainKey: { counter: rc.chainKey.index ?? 0, key: b64(rc.chainKey.key) },
585
495
  chainType: CHAIN_TYPE_RECEIVING,
586
496
  messageKeys: {}
587
497
  };
@@ -620,16 +530,12 @@ function bridgeSessionProtoToUpstreamRecord(protoBytes) {
620
530
  const current = record.currentSession ? sessionStructureToEntry(record.currentSession, -1) : null;
621
531
  if (current)
622
532
  _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").
533
+ // Synthesize descending close timestamps so removeOldSessions can sort:
534
+ // front of Rust `previous_sessions` is the most recently archived.
626
535
  const previous = record.previousSessions ?? [];
627
536
  for (let i = 0; i < previous.length; i++) {
628
537
  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])
538
+ if (entry && !_sessions[entry.indexInfo.baseKey])
633
539
  _sessions[entry.indexInfo.baseKey] = entry;
634
540
  }
635
541
  return { _sessions, version: 'v1' };
@@ -646,25 +552,23 @@ function entryToSessionStructure(entry) {
646
552
  chainKey: senderChainEntry
647
553
  ? { index: senderChainEntry.chainKey.counter, key: new Uint8Array(fromB64(senderChainEntry.chainKey.key)) }
648
554
  : { 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: []
555
+ messageKeys: [] // sender chain is sequential — no skipped cache either side
652
556
  };
653
557
  const receiverChains = [];
654
558
  for (const [k, ch] of Object.entries(entry._chains)) {
655
559
  if (ch.chainType !== CHAIN_TYPE_RECEIVING)
656
560
  continue;
657
- const ratchetPub = fromB64(k);
658
561
  receiverChains.push({
659
- senderRatchetKey: new Uint8Array(ratchetPub),
562
+ senderRatchetKey: new Uint8Array(fromB64(k)),
660
563
  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.
564
+ // JS holds 32-byte HMAC seeds; Rust holds the post-HKDF split.
565
+ // Lossless JS→Rust derivation via deriveProtoMessageKey.
665
566
  messageKeys: jsChainMessageKeysToProto(ch.messageKeys)
666
567
  });
667
568
  }
569
+ // `indexInfo.baseKey` IS the alice_base_key in either direction
570
+ // (alice=our ephemeral, bob=peer's ephemeral). Rust populates it
571
+ // symmetrically; dropping it would break find_matching_previous_session_index.
668
572
  const session = {
669
573
  rootKey: new Uint8Array(fromB64(ratchet.rootKey)),
670
574
  previousCounter: ratchet.previousCounter,
@@ -672,14 +576,6 @@ function entryToSessionStructure(entry) {
672
576
  receiverChains,
673
577
  remoteIdentityPublic: new Uint8Array(fromB64(entry.indexInfo.remoteIdentityKey)),
674
578
  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
579
  aliceBaseKey: new Uint8Array(fromB64(entry.indexInfo.baseKey))
684
580
  };
685
581
  if (entry.pendingPreKey?.baseKey) {
@@ -692,42 +588,24 @@ function entryToSessionStructure(entry) {
692
588
  return session;
693
589
  }
694
590
  function upstreamSessionRecordToProto(record) {
695
- const r = record;
696
- const sessions = r?._sessions ?? {};
591
+ const sessions = record?._sessions ?? {};
697
592
  let currentSession = null;
698
593
  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
594
  for (const entry of Object.values(sessions)) {
703
595
  const ss = entryToSessionStructure(entry);
704
- if (!currentSession && entry.indexInfo.closed === -1) {
596
+ if (!currentSession && entry.indexInfo.closed === -1)
705
597
  currentSession = ss;
706
- }
707
- else {
598
+ else
708
599
  previousSessions.push(ss);
709
- }
710
600
  }
711
- // `BridgeSessionProto` is a structural mirror of `ISessionStructure`
712
- // from `whatsapp-rust-bridge/proto-types`; the differences are nullable
713
- // vs optional fields. We cast at the boundary to avoid pulling the full
714
- // proto class types into our internal shape.
715
601
  const recordOut = proto.RecordStructure.create({
716
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
717
- currentSession: (currentSession ?? undefined),
718
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
719
- previousSessions: previousSessions
602
+ currentSession: currentSession ?? undefined,
603
+ previousSessions
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) {
@@ -1027,21 +905,19 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1027
905
  if (storeName === 'sender_key') {
1028
906
  const translated = bridgeSenderKeyToUpstream(key);
1029
907
  if (translated == null) {
1030
- // eslint-disable-line eqeqeq -- intentional null+undefined check
1031
908
  warn(`sender_key key translation failed for "${key}", falling back to passthrough`);
1032
909
  return key;
1033
910
  }
1034
911
  return translated;
1035
912
  }
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') {
913
+ // Bridge session/identity keys are the protocol address
914
+ // (`user[:dev]@server.sig`); upstream's libsignal looks under
915
+ // `signalUser.deviceId`. `bridgeAddrToBaileysAddr` does the LID/PN
916
+ // → signalUser rewrite + JID-device extraction.
917
+ if (storeName === 'session' || storeName === 'identity') {
1041
918
  const translated = bridgeAddrToBaileysAddr(key);
1042
919
  if (translated == null) {
1043
- // eslint-disable-line eqeqeq -- intentional null+undefined check
1044
- warn(`session key translation failed for "${key}", falling back to passthrough`);
920
+ warn(`${storeName} key translation failed for "${key}", falling back to passthrough`);
1045
921
  return key;
1046
922
  }
1047
923
  return translated;
@@ -1063,7 +939,7 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1063
939
  async function readBinary(type, id) {
1064
940
  try {
1065
941
  const val = await storeGetOne(type, id);
1066
- return val != null ? toBuf(val) : null; // eslint-disable-line eqeqeq -- intentional null+undefined check
942
+ return val != null ? toBuf(val) : null;
1067
943
  }
1068
944
  catch (e) {
1069
945
  warn(`GET ${type}/${id} failed:`, e);
@@ -1114,13 +990,12 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1114
990
  return readBinary(type, key);
1115
991
  }
1116
992
  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;
993
+ // `BINARY_STORES` is empty as of the move-to-converter
994
+ // migration — keeping the branch for future passthrough
995
+ // stores. No identity fallback needed: identity now goes
996
+ // through the converter, which handles both the address
997
+ // rewrite and the 32↔33-byte prefix.
998
+ return readBinary(type, key);
1124
999
  }
1125
1000
  if (route === 'bridge-only')
1126
1001
  return readBinary(type, key);
@@ -1129,7 +1004,7 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1129
1004
  const upstreamKey = translateKey(bridgeStore, key);
1130
1005
  const value = await storeGetOne(type, upstreamKey);
1131
1006
  if (value == null)
1132
- return null; // eslint-disable-line eqeqeq -- intentional null+undefined check
1007
+ return null;
1133
1008
  return converters[bridgeStore]?.toBridge(key, value) ?? toBuf(value);
1134
1009
  }
1135
1010
  catch (e) {
@@ -1202,28 +1077,6 @@ export async function wrapLegacyStore(state, saveCreds, logger) {
1202
1077
  }
1203
1078
  }
1204
1079
  };
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
1080
  }
1228
1081
  const bufferReviver = (_, value) => {
1229
1082
  if (value !== null &&