@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
@@ -1,32 +1,14 @@
1
1
  /**
2
- * Pin down the `session` cross-impl gap. Sister of the sender_key test.
3
- *
4
- * Bridge (Rust libsignal) writes:
5
- * • store: `session`
6
- * key: `{user}[:jidDev]@{server}.{signalDev}` e.g. "100000037037034@lid.0"
7
- * • value: raw protobuf bytes of `waproto.RecordStructure`
8
- * (current_session + previous_sessions, both `SessionStructure`)
9
- *
10
- * Upstream baileys (JS libsignal) writes:
11
- * store: `session` (same name, so no rename)
12
- * • key: `{signalUser}.{deviceId}` e.g. "100000037037034_1.0"
13
- * where signalUser = `${user}` for s.whatsapp.net (domainType 0),
14
- * `${user}_${domainType}` otherwise
15
- * • value: a plain JS object (NOT bytes!) with shape
16
- * `{ _sessions: { [base64BaseKey]: SessionEntry }, version: 'v1' }`
17
- * The keys store is responsible for JSON-stringifying it on disk.
18
- *
19
- * Two diffs at the boundary:
20
- * 1. KEY format: bridge `addr@server.dev` ↔ upstream `signalUser.deviceId`
21
- * 2. VALUE format: proto bytes ↔ JS object (with base64-encoded byte fields)
22
- *
23
- * If wrap-legacy-store doesn't translate, upstream's `loadSession` either
24
- * (a) doesn't find the key at all, OR
25
- * (b) finds it (Buffer of proto bytes) and throws inside SessionRecord.deserialize
26
- * → returns null → falls back to fresh PreKey signal handshake. The e2e
27
- * 1:1 phases still pass via that fallback, but every cross-impl swap then
28
- * costs an extra ~3 wire roundtrips per peer pair. These tests pin down
29
- * the contract for direct, no-re-handshake compatibility.
2
+ * Cross-impl gap for `session`:
3
+ * • Bridge: store `session`, key `{user}[:dev]@{server}.{sig}`,
4
+ * value = protobuf `RecordStructure` (current + previous_sessions).
5
+ * • Upstream: store `session`, key `{signalUser}.{deviceId}`,
6
+ * value = JS object `{ _sessions: { [base64BaseKey]: SessionEntry }, version: 'v1' }`.
7
+ *
8
+ * Without conversion, upstream's `loadSession` either misses the key or
9
+ * throws inside `SessionRecord.deserialize`, falling back to a fresh
10
+ * PreKey signal handshake on every cross-impl swap. These tests pin
11
+ * direct, no-re-handshake compatibility.
30
12
  */
31
13
  export {};
32
14
  //# sourceMappingURL=wrap-legacy-store-session.test.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"wrap-legacy-store-session.test.d.ts","sourceRoot":"","sources":["../../../src/Utils/__tests__/wrap-legacy-store-session.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG"}
1
+ {"version":3,"file":"wrap-legacy-store-session.test.d.ts","sourceRoot":"","sources":["../../../src/Utils/__tests__/wrap-legacy-store-session.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG"}
@@ -1,150 +1,48 @@
1
1
  /**
2
- * Pin down the `session` cross-impl gap. Sister of the sender_key test.
2
+ * Cross-impl gap for `session`:
3
+ * • Bridge: store `session`, key `{user}[:dev]@{server}.{sig}`,
4
+ * value = protobuf `RecordStructure` (current + previous_sessions).
5
+ * • Upstream: store `session`, key `{signalUser}.{deviceId}`,
6
+ * value = JS object `{ _sessions: { [base64BaseKey]: SessionEntry }, version: 'v1' }`.
3
7
  *
4
- * Bridge (Rust libsignal) writes:
5
- * store: `session`
6
- * key: `{user}[:jidDev]@{server}.{signalDev}` e.g. "100000037037034@lid.0"
7
- * value: raw protobuf bytes of `waproto.RecordStructure`
8
- * (current_session + previous_sessions, both `SessionStructure`)
9
- *
10
- * Upstream baileys (JS libsignal) writes:
11
- * • store: `session` (same name, so no rename)
12
- * • key: `{signalUser}.{deviceId}` e.g. "100000037037034_1.0"
13
- * where signalUser = `${user}` for s.whatsapp.net (domainType 0),
14
- * `${user}_${domainType}` otherwise
15
- * • value: a plain JS object (NOT bytes!) with shape
16
- * `{ _sessions: { [base64BaseKey]: SessionEntry }, version: 'v1' }`
17
- * The keys store is responsible for JSON-stringifying it on disk.
18
- *
19
- * Two diffs at the boundary:
20
- * 1. KEY format: bridge `addr@server.dev` ↔ upstream `signalUser.deviceId`
21
- * 2. VALUE format: proto bytes ↔ JS object (with base64-encoded byte fields)
22
- *
23
- * If wrap-legacy-store doesn't translate, upstream's `loadSession` either
24
- * (a) doesn't find the key at all, OR
25
- * (b) finds it (Buffer of proto bytes) and throws inside SessionRecord.deserialize
26
- * → returns null → falls back to fresh PreKey signal handshake. The e2e
27
- * 1:1 phases still pass via that fallback, but every cross-impl swap then
28
- * costs an extra ~3 wire roundtrips per peer pair. These tests pin down
29
- * the contract for direct, no-re-handshake compatibility.
8
+ * Without conversion, upstream's `loadSession` either misses the key or
9
+ * throws inside `SessionRecord.deserialize`, falling back to a fresh
10
+ * PreKey signal handshake on every cross-impl swap. These tests pin
11
+ * direct, no-re-handshake compatibility.
30
12
  */
31
13
  import { Buffer } from 'node:buffer';
32
14
  import { describe, test } from 'node:test';
33
- // libsignal has no .d.ts; importing the JS module directly is fine for a
34
- // node-test runtime, we just have to widen the type at the boundary.
35
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
36
- // @ts-expect-error -- no types
37
15
  import SessionRecord from 'libsignal/src/session_record.js';
38
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge proto-types ambient
39
16
  import { proto as bridgeProto } from 'whatsapp-rust-bridge/proto-types';
40
- import { wrapLegacyStore } from "../wrap-legacy-store.js";
41
- import { initAuthCreds } from "../generics.js";
42
17
  import { expect } from "../../__tests__/expect.js";
43
- // ── Helpers ──────────────────────────────────────────────────────────────
44
- function makeMemKeys() {
45
- const data = {};
46
- return {
47
- raw: data,
48
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
49
- async get(type, ids) {
50
- const bucket = data[type] ?? {};
51
- const out = {};
52
- for (const id of ids)
53
- out[id] = bucket[id] ?? null;
54
- return out;
55
- },
56
- async set(updates) {
57
- for (const [type, bucket] of Object.entries(updates)) {
58
- data[type] ?? (data[type] = {});
59
- for (const [id, val] of Object.entries(bucket)) {
60
- if (val === null || val === undefined)
61
- delete data[type][id];
62
- else
63
- data[type][id] = val;
64
- }
65
- }
66
- }
67
- };
68
- }
69
- async function makeWrapped() {
70
- const creds = initAuthCreds();
71
- const keys = makeMemKeys();
72
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
- const state = { creds, keys: keys };
74
- const wrapped = await wrapLegacyStore(state, async () => { });
75
- return { wrapped, keys, creds };
76
- }
77
- const fill = (n, base) => new Uint8Array(n).map((_, i) => (i * base + base) & 0xff);
78
- /** Build a realistic, fully-populated `RecordStructure` proto byte blob —
79
- * matching what Rust libsignal would produce for an established Signal
80
- * session that has both a sender chain and a receiver chain. */
81
- function buildBridgeSessionBytes(opts) {
82
- const { rootKey = fill(32, 1), previousCounter = 0, senderRatchetPub = fill(33, 2), senderRatchetPriv = fill(32, 3), senderChainKey = fill(32, 4), senderChainIndex = 0, receiverRatchetPub = fill(33, 5), receiverChainKey = fill(32, 6), receiverChainIndex = 1, aliceBaseKey = fill(33, 7), remoteIdentity = fill(33, 8), remoteRegistrationId = 1234, localRegistrationId = 5678 } = opts;
83
- const session = bridgeProto.SessionStructure.create({
84
- sessionVersion: 3,
85
- localIdentityPublic: fill(33, 9),
86
- remoteIdentityPublic: remoteIdentity,
87
- rootKey,
88
- previousCounter,
89
- senderChain: {
90
- senderRatchetKey: senderRatchetPub,
91
- senderRatchetKeyPrivate: senderRatchetPriv,
92
- chainKey: { index: senderChainIndex, key: senderChainKey },
93
- messageKeys: []
94
- },
95
- receiverChains: [
96
- {
97
- senderRatchetKey: receiverRatchetPub,
98
- chainKey: { index: receiverChainIndex, key: receiverChainKey },
99
- messageKeys: []
100
- }
101
- ],
102
- remoteRegistrationId,
103
- localRegistrationId,
104
- aliceBaseKey
105
- });
106
- const record = bridgeProto.RecordStructure.create({
107
- currentSession: session,
108
- previousSessions: []
109
- });
110
- return bridgeProto.RecordStructure.encode(record).finish();
111
- }
112
- const SAMPLE_BRIDGE_KEY_LID = '100000037037034@lid.0';
113
- const EXPECTED_UPSTREAM_KEY_LID = '100000037037034_1.0';
114
- const SAMPLE_BRIDGE_KEY_PN = '559980000003@s.whatsapp.net.0';
115
- const EXPECTED_UPSTREAM_KEY_PN = '559980000003.0';
116
- const SAMPLE_BRIDGE_KEY_PN_DEV = '559980000003:5@s.whatsapp.net.0';
117
- const EXPECTED_UPSTREAM_KEY_PN_DEV = '559980000003.5';
118
- // ── Tests ────────────────────────────────────────────────────────────────
119
- describe('wrap-legacy-store: session key-name translation (cross-impl)', () => {
120
- test('bridge SET writes under the upstream-formatted key (LID)', async () => {
18
+ import { BRIDGE_SESSION_KEY_LID, BRIDGE_SESSION_KEY_PN, BRIDGE_SESSION_KEY_PN_DEV, UPSTREAM_SESSION_KEY_LID, UPSTREAM_SESSION_KEY_PN, UPSTREAM_SESSION_KEY_PN_DEV, buildBridgeSessionBytes, fill, makeWrapped } from "./_legacy-store-fixtures.js";
19
+ describe('wrap-legacy-store: session key-name translation', () => {
20
+ test('LID upstream `_1.deviceId`', async () => {
121
21
  const { wrapped, keys } = await makeWrapped();
122
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, buildBridgeSessionBytes({}));
123
- expect(keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID]).toBeDefined();
124
- // The bridge-shaped key MUST NOT be written; otherwise upstream's
125
- // `loadSession(<upstreamKey>)` won't find anything.
126
- expect(keys.raw['session']?.[SAMPLE_BRIDGE_KEY_LID]).toBeUndefined();
22
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, buildBridgeSessionBytes());
23
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID]).toBeDefined();
24
+ expect(keys.raw['session']?.[BRIDGE_SESSION_KEY_LID]).toBeUndefined();
127
25
  });
128
- test('bridge SET writes under the upstream-formatted key (PN, no device)', async () => {
26
+ test('PN no-device bare `{user}.{signalDev}`', async () => {
129
27
  const { wrapped, keys } = await makeWrapped();
130
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_PN, buildBridgeSessionBytes({}));
131
- expect(keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_PN]).toBeDefined();
28
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, buildBridgeSessionBytes());
29
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN]).toBeDefined();
132
30
  });
133
- test('bridge SET writes under the upstream-formatted key (PN with device)', async () => {
31
+ test('PN with JID device device suffix preserved', async () => {
134
32
  const { wrapped, keys } = await makeWrapped();
135
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_PN_DEV, buildBridgeSessionBytes({}));
136
- expect(keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_PN_DEV]).toBeDefined();
33
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN_DEV, buildBridgeSessionBytes());
34
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN_DEV]).toBeDefined();
137
35
  });
138
36
  });
139
- describe('wrap-legacy-store: session value-byte translation (cross-impl)', () => {
140
- test('bridge-written proto bytes are loadable by upstream SessionRecord.deserialize', async () => {
37
+ describe('wrap-legacy-store: session value-byte translation', () => {
38
+ test('bridge proto bytes load via upstream `SessionRecord.deserialize` as OPEN', async () => {
141
39
  const { wrapped, keys } = await makeWrapped();
142
- const aliceBaseKey = Buffer.from(new Uint8Array(33).map((_, i) => (i + 100) & 0xff));
143
- const rootKey = Buffer.from(new Uint8Array(32).map((_, i) => (i * 3 + 1) & 0xff));
144
- const remoteIdentity = Buffer.from(new Uint8Array(33).map((_, i) => (i * 5 + 7) & 0xff));
145
- const senderRatchetPub = Buffer.from(new Uint8Array(33).map((_, i) => (i * 11 + 13) & 0xff));
146
- const senderRatchetPriv = Buffer.from(new Uint8Array(32).map((_, i) => (i * 13 + 17) & 0xff));
147
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, buildBridgeSessionBytes({
40
+ const aliceBaseKey = Buffer.from(fill(33, 100));
41
+ const rootKey = Buffer.from(fill(32, 11));
42
+ const remoteIdentity = Buffer.from(fill(33, 17));
43
+ const senderRatchetPub = Buffer.from(fill(33, 23));
44
+ const senderRatchetPriv = Buffer.from(fill(32, 29));
45
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, buildBridgeSessionBytes({
148
46
  rootKey: new Uint8Array(rootKey),
149
47
  aliceBaseKey: new Uint8Array(aliceBaseKey),
150
48
  remoteIdentity: new Uint8Array(remoteIdentity),
@@ -153,15 +51,10 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
153
51
  remoteRegistrationId: 99,
154
52
  previousCounter: 4
155
53
  }));
156
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID];
157
- expect(stored).toBeDefined();
158
- // THE call upstream's loadSession makes. If it throws or produces a
159
- // session with no open entries, the regression isn't fixed.
160
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- libsignal lacks .d.ts
54
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
161
55
  const rec = SessionRecord.deserialize(stored);
162
56
  expect(rec.haveOpenSession()).toBe(true);
163
57
  const open = rec.getOpenSession();
164
- expect(open).toBeDefined();
165
58
  expect(open.registrationId).toBe(99);
166
59
  expect(open.currentRatchet.previousCounter).toBe(4);
167
60
  expect(Buffer.from(open.currentRatchet.rootKey).equals(rootKey)).toBe(true);
@@ -169,54 +62,44 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
169
62
  expect(Buffer.from(open.currentRatchet.ephemeralKeyPair.privKey).equals(senderRatchetPriv)).toBe(true);
170
63
  expect(Buffer.from(open.indexInfo.baseKey).equals(aliceBaseKey)).toBe(true);
171
64
  expect(Buffer.from(open.indexInfo.remoteIdentityKey).equals(remoteIdentity)).toBe(true);
172
- // indexInfo.closed === -1 means the session is OPEN; cross-impl
173
- // swap of an open session is the whole point.
174
65
  expect(open.indexInfo.closed).toBe(-1);
175
- // We expect at least one chain populated (sender + receiver).
176
66
  expect(Object.keys(open._chains).length).toBeGreaterThanOrEqual(1);
177
67
  });
178
- test('upstream-written JS-shaped object round-trips through bridge GET to valid proto bytes', async () => {
68
+ test('upstream JS object bridge GET produces valid proto with same fields', async () => {
179
69
  const { wrapped, keys } = await makeWrapped();
180
- // Build a SessionRecord the upstream way (start from a synthetic
181
- // session shape, since libsignal exposes no public constructor that
182
- // fully populates the entry).
183
70
  const baseKey = Buffer.alloc(33, 0xa0);
184
71
  const rootKey = Buffer.alloc(32, 0xb0);
185
72
  const remoteIdent = Buffer.alloc(33, 0xc0);
186
73
  const ephPub = Buffer.alloc(33, 0xd0);
187
74
  const ephPriv = Buffer.alloc(32, 0xe0);
188
75
  const lastRemoteEph = Buffer.alloc(33, 0xf0);
189
- const upstreamRecord = {
190
- _sessions: {
191
- [baseKey.toString('base64')]: {
192
- registrationId: 4242,
193
- currentRatchet: {
194
- ephemeralKeyPair: {
195
- pubKey: ephPub.toString('base64'),
196
- privKey: ephPriv.toString('base64')
76
+ keys.raw['session'] = {
77
+ [UPSTREAM_SESSION_KEY_LID]: {
78
+ _sessions: {
79
+ [baseKey.toString('base64')]: {
80
+ registrationId: 4242,
81
+ currentRatchet: {
82
+ ephemeralKeyPair: { pubKey: ephPub.toString('base64'), privKey: ephPriv.toString('base64') },
83
+ lastRemoteEphemeralKey: lastRemoteEph.toString('base64'),
84
+ previousCounter: 7,
85
+ rootKey: rootKey.toString('base64')
197
86
  },
198
- lastRemoteEphemeralKey: lastRemoteEph.toString('base64'),
199
- previousCounter: 7,
200
- rootKey: rootKey.toString('base64')
201
- },
202
- indexInfo: {
203
- baseKey: baseKey.toString('base64'),
204
- baseKeyType: 1, // OURS — we initiated
205
- closed: -1,
206
- used: Date.now(),
207
- created: Date.now(),
208
- remoteIdentityKey: remoteIdent.toString('base64')
209
- },
210
- _chains: {}
211
- }
212
- },
213
- version: 'v1'
87
+ indexInfo: {
88
+ baseKey: baseKey.toString('base64'),
89
+ baseKeyType: 1,
90
+ closed: -1,
91
+ used: Date.now(),
92
+ created: Date.now(),
93
+ remoteIdentityKey: remoteIdent.toString('base64')
94
+ },
95
+ _chains: {}
96
+ }
97
+ },
98
+ version: 'v1'
99
+ }
214
100
  };
215
- keys.raw['session'] = { [EXPECTED_UPSTREAM_KEY_LID]: upstreamRecord };
216
- const protoOut = (await wrapped.get('session', SAMPLE_BRIDGE_KEY_LID));
217
- expect(protoOut).not.toBe(null);
101
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
218
102
  const decoded = bridgeProto.RecordStructure.decode(protoOut);
219
- expect(decoded.currentSession).toBeDefined();
220
103
  const cs = decoded.currentSession;
221
104
  expect(cs.remoteRegistrationId).toBe(4242);
222
105
  expect(cs.previousCounter).toBe(7);
@@ -226,32 +109,23 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
226
109
  expect(Buffer.from(cs.senderChain.senderRatchetKey).equals(ephPub)).toBe(true);
227
110
  expect(Buffer.from(cs.senderChain.senderRatchetKeyPrivate).equals(ephPriv)).toBe(true);
228
111
  });
229
- test('round-trip: bridge SET → upstream loadSession+haveOpenSession → still open + correct identity', async () => {
112
+ test('round-trip: bridge SET → upstream loadSession → still open + same identity', async () => {
230
113
  const { wrapped, keys } = await makeWrapped();
231
114
  const remoteIdentity = Buffer.alloc(33, 0x11);
232
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_PN, buildBridgeSessionBytes({ remoteIdentity: new Uint8Array(remoteIdentity), remoteRegistrationId: 7 }));
233
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_PN];
234
- expect(stored).toBeDefined();
235
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
- const rec = SessionRecord.deserialize(stored);
237
- expect(rec.haveOpenSession()).toBe(true);
115
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, buildBridgeSessionBytes({ remoteIdentity: new Uint8Array(remoteIdentity), remoteRegistrationId: 7 }));
116
+ const rec = SessionRecord.deserialize(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN]);
238
117
  const open = rec.getOpenSession();
239
118
  expect(open.registrationId).toBe(7);
240
119
  expect(Buffer.from(open.indexInfo.remoteIdentityKey).equals(remoteIdentity)).toBe(true);
241
120
  });
242
121
  });
243
- describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, lastRemoteEph)', () => {
244
- test('lastRemoteEphemeralKey does NOT collide with senderRatchetKey when receiverChains is empty (no-collide bug fix)', async () => {
245
- // Regression for the bug discovered in audit: previously the
246
- // converter used senderRatchetKey as a placeholder for
247
- // lastRemoteEphemeralKey when receiverChains was empty, which
248
- // caused upstream's `maybeStepRatchet` `getChain(lastRemoteEph)`
249
- // to return the SENDER chain and `delete chainKey.key` corrupted
250
- // the outbound encryption channel.
122
+ describe('wrap-legacy-store: session edge cases', () => {
123
+ test('lastRemoteEphemeralKey does NOT collide with senderRatchetKey when receiverChains empty', async () => {
124
+ // Regression: the converter previously used senderRatchetKey as a
125
+ // placeholder, which made upstream's `maybeStepRatchet` close the
126
+ // SENDER chain on the next peer ratchet (corrupting outbound
127
+ // encryption). Now uses aliceBaseKey (when bob) or empty buffer.
251
128
  const { wrapped, keys } = await makeWrapped();
252
- // Build a freshly-initiated alice-side session: sender chain
253
- // populated, no receiverChain yet, pendingPreKey set (alice waits
254
- // for bob's first reply).
255
129
  const senderRatchetPub = fill(33, 2);
256
130
  const aliceBaseKey = fill(33, 7);
257
131
  const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
@@ -274,25 +148,18 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
274
148
  }),
275
149
  previousSessions: []
276
150
  })).finish();
277
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, protoBytes);
278
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID];
279
- expect(stored).toBeDefined();
151
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, protoBytes);
152
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
280
153
  const entry = Object.values(stored._sessions)[0];
281
- // The last-remote-eph MUST NOT equal the sender's ratchet pubkey,
282
- // otherwise upstream's maybeStepRatchet will close the SENDER chain
283
- // when the peer ratchets next.
284
154
  expect(entry.currentRatchet.lastRemoteEphemeralKey).not.toBe(Buffer.from(senderRatchetPub).toString('base64'));
285
155
  });
286
- test('baseKeyType derives from pendingPreKey: present → OURS (alice), absent → THEIRS (bob)', async () => {
287
- // Regression for the bug where I always set OURS when aliceBaseKey
288
- // was set (which is ALWAYS in established sessions), causing
289
- // upstream's `getSession(byBaseKey)` to throw on every lookup.
290
- const { wrapped, keys } = await makeWrapped();
291
- // Bob-side session: established, no pendingPreKey, aliceBaseKey set
292
- // (it's the peer's ephemeral). MUST land as THEIRS so upstream can
293
- // look it up by baseKey.
294
- const bobSideBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
295
- currentSession: bridgeProto.SessionStructure.create({
156
+ test('baseKeyType: pendingPreKey present → OURS (alice), absent → THEIRS (bob)', async () => {
157
+ // Regression: previously set OURS whenever aliceBaseKey existed
158
+ // (always), making upstream `getSession(byBaseKey)` throw.
159
+ const baseKeyTypeFor = async (withPendingPreKey) => {
160
+ const { wrapped, keys } = await makeWrapped();
161
+ const aliceBaseKey = fill(33, 7);
162
+ const session = bridgeProto.SessionStructure.create({
296
163
  sessionVersion: 3,
297
164
  rootKey: fill(32, 1),
298
165
  previousCounter: 0,
@@ -302,55 +169,28 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
302
169
  chainKey: { index: 0, key: fill(32, 4) },
303
170
  messageKeys: []
304
171
  },
305
- receiverChains: [
306
- {
307
- senderRatchetKey: fill(33, 5),
308
- chainKey: { index: 1, key: fill(32, 6) },
309
- messageKeys: []
310
- }
311
- ],
312
- remoteIdentityPublic: fill(33, 8),
313
- remoteRegistrationId: 1,
314
- aliceBaseKey: fill(33, 7) // present but we're bob (no pendingPreKey)
315
- }),
316
- previousSessions: []
317
- })).finish();
318
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_PN, bobSideBytes);
319
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_PN];
320
- const bobEntry = Object.values(stored._sessions)[0];
321
- expect(bobEntry.indexInfo.baseKeyType).toBe(2); // THEIRS
322
- // Now alice-side: same session shape but with pendingPreKey → OURS.
323
- const { wrapped: w2, keys: k2 } = await makeWrapped();
324
- const aliceBaseKey = fill(33, 7);
325
- const aliceBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
326
- currentSession: bridgeProto.SessionStructure.create({
327
- sessionVersion: 3,
328
- rootKey: fill(32, 1),
329
- previousCounter: 0,
330
- senderChain: {
331
- senderRatchetKey: fill(33, 2),
332
- senderRatchetKeyPrivate: fill(32, 3),
333
- chainKey: { index: 0, key: fill(32, 4) },
334
- messageKeys: []
335
- },
336
- receiverChains: [],
172
+ receiverChains: withPendingPreKey
173
+ ? []
174
+ : [
175
+ {
176
+ senderRatchetKey: fill(33, 5),
177
+ chainKey: { index: 1, key: fill(32, 6) },
178
+ messageKeys: []
179
+ }
180
+ ],
337
181
  remoteIdentityPublic: fill(33, 8),
338
182
  remoteRegistrationId: 1,
339
183
  aliceBaseKey,
340
- pendingPreKey: { preKeyId: 1, signedPreKeyId: 2, baseKey: aliceBaseKey }
341
- }),
342
- previousSessions: []
343
- })).finish();
344
- await w2.set('session', SAMPLE_BRIDGE_KEY_PN, aliceBytes);
345
- const aliceStored = k2.raw['session']?.[EXPECTED_UPSTREAM_KEY_PN];
346
- const aliceEntry = Object.values(aliceStored._sessions)[0];
347
- expect(aliceEntry.indexInfo.baseKeyType).toBe(1); // OURS
184
+ ...(withPendingPreKey ? { pendingPreKey: { preKeyId: 1, signedPreKeyId: 2, baseKey: aliceBaseKey } } : {})
185
+ });
186
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({ currentSession: session, previousSessions: [] })).finish());
187
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN];
188
+ return Object.values(stored._sessions)[0].indexInfo.baseKeyType;
189
+ };
190
+ expect(await baseKeyTypeFor(true)).toBe(1); // OURS
191
+ expect(await baseKeyTypeFor(false)).toBe(2); // THEIRS
348
192
  });
349
- test('multi-session: currentSession + previousSessions[] all round-trip into upstream _sessions', async () => {
350
- // Cover the path where Rust archives sessions into previous_sessions
351
- // (e.g. after identity change). Upstream should see one OPEN entry
352
- // (closed=-1) plus one or more closed entries that share the same
353
- // peer key.
193
+ test('multi-session: currentSession + previousSessions[] all land in upstream `_sessions`', async () => {
354
194
  const { wrapped, keys } = await makeWrapped();
355
195
  const mkSession = (rootSeed, baseKeySeed) => bridgeProto.SessionStructure.create({
356
196
  sessionVersion: 3,
@@ -373,38 +213,28 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
373
213
  remoteRegistrationId: 1,
374
214
  aliceBaseKey: fill(33, baseKeySeed)
375
215
  });
376
- const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
216
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
377
217
  currentSession: mkSession(1, 100),
378
218
  previousSessions: [mkSession(2, 200), mkSession(3, 250)]
379
- })).finish();
380
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, protoBytes);
381
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID];
219
+ })).finish());
220
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
382
221
  expect(stored.version).toBe('v1');
383
- // 1 current + 2 previous, distinct baseKeys → 3 entries.
384
222
  expect(Object.keys(stored._sessions).length).toBe(3);
385
- // Exactly one OPEN session (closed=-1).
386
223
  const open = Object.values(stored._sessions).filter(e => e.indexInfo.closed === -1);
387
224
  expect(open.length).toBe(1);
388
225
  expect(open[0].indexInfo.baseKey).toBe(Buffer.from(fill(33, 100)).toString('base64'));
389
- // The other two are CLOSED with positive timestamps. Most recent
390
- // archived (previous[0]) must have a LARGER timestamp than
391
- // previous[1] so removeOldSessions evicts the oldest first
392
- // (matching Rust's archive ordering: front = newest).
226
+ // Front of Rust's previous_sessions = newest archived must get
227
+ // the LARGER `closed` timestamp so upstream's removeOldSessions
228
+ // evicts the oldest first.
393
229
  const closedEntries = Object.values(stored._sessions).filter(e => e.indexInfo.closed > 0);
394
- expect(closedEntries.length).toBe(2);
395
230
  const newer = closedEntries.find(e => e.indexInfo.baseKey === Buffer.from(fill(33, 200)).toString('base64'));
396
231
  const older = closedEntries.find(e => e.indexInfo.baseKey === Buffer.from(fill(33, 250)).toString('base64'));
397
- expect(newer).toBeDefined();
398
- expect(older).toBeDefined();
399
232
  expect(newer.indexInfo.closed).toBeGreaterThan(older.indexInfo.closed);
400
233
  });
401
- test('pendingPreKey: round-trips through bridge→upstream→bridge with field rename preserved', async () => {
402
- // Bridge proto: pendingPreKey { preKeyId, signedPreKeyId, baseKey }
403
- // Upstream JS: session.pendingPreKey { preKeyId, signedKeyId, baseKey }
404
- // (yes, the field name differs — `signedPreKeyId` vs `signedKeyId`)
234
+ test('pendingPreKey round-trips with field rename `signedPreKeyId↔signedKeyId`', async () => {
405
235
  const { wrapped, keys } = await makeWrapped();
406
236
  const aliceBaseKey = fill(33, 7);
407
- const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
237
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
408
238
  currentSession: bridgeProto.SessionStructure.create({
409
239
  sessionVersion: 3,
410
240
  rootKey: fill(32, 1),
@@ -422,46 +252,36 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
422
252
  pendingPreKey: { preKeyId: 314, signedPreKeyId: 271, baseKey: aliceBaseKey }
423
253
  }),
424
254
  previousSessions: []
425
- })).finish();
426
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, protoBytes);
427
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID];
255
+ })).finish());
256
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
428
257
  const entry = Object.values(stored._sessions)[0];
429
- expect(entry.pendingPreKey).toBeDefined();
430
258
  expect(entry.pendingPreKey.preKeyId).toBe(314);
431
- // Field rename: bridge's `signedPreKeyId`upstream's `signedKeyId`.
432
- expect(entry.pendingPreKey.signedKeyId).toBe(271);
259
+ expect(entry.pendingPreKey.signedKeyId).toBe(271); // rename: signedPreKeyId → signedKeyId
433
260
  expect(entry.pendingPreKey.baseKey).toBe(Buffer.from(aliceBaseKey).toString('base64'));
434
- // Now read it back through the bridge GET path — pendingPreKey
435
- // should round-trip with the original field names.
436
- const protoOut = (await wrapped.get('session', SAMPLE_BRIDGE_KEY_LID));
261
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
437
262
  const decoded = bridgeProto.RecordStructure.decode(protoOut);
438
263
  expect(decoded.currentSession?.pendingPreKey?.preKeyId).toBe(314);
439
264
  expect(decoded.currentSession?.pendingPreKey?.signedPreKeyId).toBe(271);
440
265
  });
441
- test('aliceBaseKey is preserved on JS→bridge round-trip regardless of baseKeyType', async () => {
442
- // Regression for the matching-second bug discovered in audit: the
443
- // reverse converter only set aliceBaseKey when baseKeyType=OURS, so
444
- // bob-side sessions lost aliceBaseKey, breaking Rust's
445
- // `find_matching_previous_session_index` lookups on subsequent
446
- // PreKeySignal arrivals.
266
+ test('aliceBaseKey preserved on JS→bridge regardless of baseKeyType (bob-side restoration)', async () => {
267
+ // Regression: previously dropped aliceBaseKey when baseKeyType=THEIRS,
268
+ // breaking Rust's `find_matching_previous_session_index` lookup on
269
+ // subsequent PreKeySignal arrivals.
447
270
  const { wrapped, keys } = await makeWrapped();
448
271
  const baseKey = Buffer.alloc(33, 0xa0);
449
- const remoteIdent = Buffer.alloc(33, 0xc0);
450
- const ephPub = Buffer.alloc(33, 0xd0);
451
- const ephPriv = Buffer.alloc(32, 0xe0);
452
- const lastRemoteEph = Buffer.alloc(33, 0xf0);
453
- const rootKey = Buffer.alloc(32, 0xb0);
454
- // Bob-side stored object (baseKeyType=THEIRS, no pendingPreKey).
455
272
  keys.raw['session'] = {
456
- [EXPECTED_UPSTREAM_KEY_LID]: {
273
+ [UPSTREAM_SESSION_KEY_LID]: {
457
274
  _sessions: {
458
275
  [baseKey.toString('base64')]: {
459
276
  registrationId: 4242,
460
277
  currentRatchet: {
461
- ephemeralKeyPair: { pubKey: ephPub.toString('base64'), privKey: ephPriv.toString('base64') },
462
- lastRemoteEphemeralKey: lastRemoteEph.toString('base64'),
278
+ ephemeralKeyPair: {
279
+ pubKey: Buffer.alloc(33, 0xd0).toString('base64'),
280
+ privKey: Buffer.alloc(32, 0xe0).toString('base64')
281
+ },
282
+ lastRemoteEphemeralKey: Buffer.alloc(33, 0xf0).toString('base64'),
463
283
  previousCounter: 0,
464
- rootKey: rootKey.toString('base64')
284
+ rootKey: Buffer.alloc(32, 0xb0).toString('base64')
465
285
  },
466
286
  indexInfo: {
467
287
  baseKey: baseKey.toString('base64'),
@@ -469,7 +289,7 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
469
289
  closed: -1,
470
290
  used: Date.now(),
471
291
  created: Date.now(),
472
- remoteIdentityKey: remoteIdent.toString('base64')
292
+ remoteIdentityKey: Buffer.alloc(33, 0xc0).toString('base64')
473
293
  },
474
294
  _chains: {}
475
295
  }
@@ -477,11 +297,8 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
477
297
  version: 'v1'
478
298
  }
479
299
  };
480
- const protoOut = (await wrapped.get('session', SAMPLE_BRIDGE_KEY_LID));
300
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
481
301
  const decoded = bridgeProto.RecordStructure.decode(protoOut);
482
- // aliceBaseKey MUST be set even when we're bob (baseKeyType=THEIRS),
483
- // otherwise Rust's session-matching path breaks.
484
- expect(decoded.currentSession?.aliceBaseKey).toBeDefined();
485
302
  expect(Buffer.from(decoded.currentSession.aliceBaseKey).equals(baseKey)).toBe(true);
486
303
  });
487
304
  });