@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.
- package/lib/Bridge/__tests__/dts-drift.test.js +1 -3
- package/lib/Bridge/__tests__/dts-drift.test.js.map +1 -1
- package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts +73 -0
- package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts.map +1 -0
- package/lib/Utils/__tests__/_legacy-store-fixtures.js +113 -0
- package/lib/Utils/__tests__/_legacy-store-fixtures.js.map +1 -0
- package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts +10 -0
- package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts.map +1 -0
- package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js +291 -0
- package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js.map +1 -0
- package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts +4 -19
- package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts.map +1 -1
- package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js +31 -97
- package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js.map +1 -1
- package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts +6 -27
- package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts.map +1 -1
- package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js +49 -177
- package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js.map +1 -1
- package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts +10 -28
- package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts.map +1 -1
- package/lib/Utils/__tests__/wrap-legacy-store-session.test.js +116 -299
- package/lib/Utils/__tests__/wrap-legacy-store-session.test.js.map +1 -1
- package/lib/Utils/wrap-legacy-store.d.ts.map +1 -1
- package/lib/Utils/wrap-legacy-store.js +142 -289
- package/lib/Utils/wrap-legacy-store.js.map +1 -1
- 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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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:
|
|
252
|
-
phone_number:
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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;
|
|
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
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
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
|
-
|
|
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
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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:
|
|
392
|
-
private:
|
|
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:
|
|
367
|
+
seed: sk_toBuffer(mk.seed)
|
|
397
368
|
}))
|
|
398
369
|
}));
|
|
399
|
-
return Buffer.from(JSON.stringify(states,
|
|
370
|
+
return Buffer.from(JSON.stringify(states, bufferJsonReplacer), 'utf-8');
|
|
400
371
|
}
|
|
401
372
|
function upstreamSenderKeyJsonToProto(jsonBuf) {
|
|
402
|
-
const
|
|
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
|
|
412
|
-
private: s.senderSigningKey?.private
|
|
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:
|
|
383
|
+
seed: sk_toBytes(mk.seed)
|
|
417
384
|
}))
|
|
418
385
|
}));
|
|
419
|
-
|
|
420
|
-
return proto.SenderKeyRecordStructure.encode(struct).finish();
|
|
386
|
+
return proto.SenderKeyRecordStructure.encode(proto.SenderKeyRecordStructure.create({ senderKeyStates })).finish();
|
|
421
387
|
}
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
//
|
|
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);
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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),
|
|
504
|
-
macKey: new Uint8Array(t2),
|
|
505
|
-
iv: new Uint8Array(t3.subarray(0, 16))
|
|
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
|
|
520
|
-
|
|
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
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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:
|
|
540
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
//
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
//
|
|
624
|
-
//
|
|
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
|
-
//
|
|
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(
|
|
562
|
+
senderRatchetKey: new Uint8Array(fromB64(k)),
|
|
660
563
|
chainKey: { index: ch.chainKey.counter, key: new Uint8Array(fromB64(ch.chainKey.key)) },
|
|
661
|
-
//
|
|
662
|
-
//
|
|
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
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
//
|
|
724
|
-
//
|
|
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
|
|
1037
|
-
// upstream's libsignal looks under
|
|
1038
|
-
// `bridgeAddrToBaileysAddr`
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
|
|
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;
|
|
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 &&
|