@oxidezap/baileyrs 0.0.8 → 0.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts +65 -0
  2. package/lib/Utils/__tests__/_legacy-store-fixtures.d.ts.map +1 -0
  3. package/lib/Utils/__tests__/_legacy-store-fixtures.js +107 -0
  4. package/lib/Utils/__tests__/_legacy-store-fixtures.js.map +1 -0
  5. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts +10 -0
  6. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.d.ts.map +1 -0
  7. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js +298 -0
  8. package/lib/Utils/__tests__/wrap-legacy-store-coverage.test.js.map +1 -0
  9. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts +4 -19
  10. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.d.ts.map +1 -1
  11. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js +31 -97
  12. package/lib/Utils/__tests__/wrap-legacy-store-lid-mapping.test.js.map +1 -1
  13. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts +6 -27
  14. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.d.ts.map +1 -1
  15. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js +48 -175
  16. package/lib/Utils/__tests__/wrap-legacy-store-sender-key.test.js.map +1 -1
  17. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts +10 -28
  18. package/lib/Utils/__tests__/wrap-legacy-store-session.test.d.ts.map +1 -1
  19. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js +118 -297
  20. package/lib/Utils/__tests__/wrap-legacy-store-session.test.js.map +1 -1
  21. package/lib/Utils/wrap-legacy-store.d.ts.map +1 -1
  22. package/lib/Utils/wrap-legacy-store.js +128 -273
  23. package/lib/Utils/wrap-legacy-store.js.map +1 -1
  24. package/package.json +4 -1
@@ -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,50 @@
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
15
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
36
- // @ts-expect-error -- no types
16
+ // @ts-expect-error -- libsignal lacks .d.ts
37
17
  import SessionRecord from 'libsignal/src/session_record.js';
38
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge proto-types ambient
39
18
  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
19
  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 () => {
20
+ 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";
21
+ describe('wrap-legacy-store: session key-name translation', () => {
22
+ test('LID upstream `_1.deviceId`', async () => {
121
23
  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();
24
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, buildBridgeSessionBytes());
25
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID]).toBeDefined();
26
+ expect(keys.raw['session']?.[BRIDGE_SESSION_KEY_LID]).toBeUndefined();
127
27
  });
128
- test('bridge SET writes under the upstream-formatted key (PN, no device)', async () => {
28
+ test('PN no-device bare `{user}.{signalDev}`', async () => {
129
29
  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();
30
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, buildBridgeSessionBytes());
31
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN]).toBeDefined();
132
32
  });
133
- test('bridge SET writes under the upstream-formatted key (PN with device)', async () => {
33
+ test('PN with JID device device suffix preserved', async () => {
134
34
  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();
35
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN_DEV, buildBridgeSessionBytes());
36
+ expect(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN_DEV]).toBeDefined();
137
37
  });
138
38
  });
139
- describe('wrap-legacy-store: session value-byte translation (cross-impl)', () => {
140
- test('bridge-written proto bytes are loadable by upstream SessionRecord.deserialize', async () => {
39
+ describe('wrap-legacy-store: session value-byte translation', () => {
40
+ test('bridge proto bytes load via upstream `SessionRecord.deserialize` as OPEN', async () => {
141
41
  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({
42
+ const aliceBaseKey = Buffer.from(fill(33, 100));
43
+ const rootKey = Buffer.from(fill(32, 11));
44
+ const remoteIdentity = Buffer.from(fill(33, 17));
45
+ const senderRatchetPub = Buffer.from(fill(33, 23));
46
+ const senderRatchetPriv = Buffer.from(fill(32, 29));
47
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, buildBridgeSessionBytes({
148
48
  rootKey: new Uint8Array(rootKey),
149
49
  aliceBaseKey: new Uint8Array(aliceBaseKey),
150
50
  remoteIdentity: new Uint8Array(remoteIdentity),
@@ -153,15 +53,11 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
153
53
  remoteRegistrationId: 99,
154
54
  previousCounter: 4
155
55
  }));
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
56
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
161
58
  const rec = SessionRecord.deserialize(stored);
162
59
  expect(rec.haveOpenSession()).toBe(true);
163
60
  const open = rec.getOpenSession();
164
- expect(open).toBeDefined();
165
61
  expect(open.registrationId).toBe(99);
166
62
  expect(open.currentRatchet.previousCounter).toBe(4);
167
63
  expect(Buffer.from(open.currentRatchet.rootKey).equals(rootKey)).toBe(true);
@@ -169,54 +65,44 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
169
65
  expect(Buffer.from(open.currentRatchet.ephemeralKeyPair.privKey).equals(senderRatchetPriv)).toBe(true);
170
66
  expect(Buffer.from(open.indexInfo.baseKey).equals(aliceBaseKey)).toBe(true);
171
67
  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
68
  expect(open.indexInfo.closed).toBe(-1);
175
- // We expect at least one chain populated (sender + receiver).
176
69
  expect(Object.keys(open._chains).length).toBeGreaterThanOrEqual(1);
177
70
  });
178
- test('upstream-written JS-shaped object round-trips through bridge GET to valid proto bytes', async () => {
71
+ test('upstream JS object bridge GET produces valid proto with same fields', async () => {
179
72
  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
73
  const baseKey = Buffer.alloc(33, 0xa0);
184
74
  const rootKey = Buffer.alloc(32, 0xb0);
185
75
  const remoteIdent = Buffer.alloc(33, 0xc0);
186
76
  const ephPub = Buffer.alloc(33, 0xd0);
187
77
  const ephPriv = Buffer.alloc(32, 0xe0);
188
78
  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')
79
+ keys.raw['session'] = {
80
+ [UPSTREAM_SESSION_KEY_LID]: {
81
+ _sessions: {
82
+ [baseKey.toString('base64')]: {
83
+ registrationId: 4242,
84
+ currentRatchet: {
85
+ ephemeralKeyPair: { pubKey: ephPub.toString('base64'), privKey: ephPriv.toString('base64') },
86
+ lastRemoteEphemeralKey: lastRemoteEph.toString('base64'),
87
+ previousCounter: 7,
88
+ rootKey: rootKey.toString('base64')
197
89
  },
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'
90
+ indexInfo: {
91
+ baseKey: baseKey.toString('base64'),
92
+ baseKeyType: 1,
93
+ closed: -1,
94
+ used: Date.now(),
95
+ created: Date.now(),
96
+ remoteIdentityKey: remoteIdent.toString('base64')
97
+ },
98
+ _chains: {}
99
+ }
100
+ },
101
+ version: 'v1'
102
+ }
214
103
  };
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);
104
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
218
105
  const decoded = bridgeProto.RecordStructure.decode(protoOut);
219
- expect(decoded.currentSession).toBeDefined();
220
106
  const cs = decoded.currentSession;
221
107
  expect(cs.remoteRegistrationId).toBe(4242);
222
108
  expect(cs.previousCounter).toBe(7);
@@ -226,32 +112,24 @@ describe('wrap-legacy-store: session value-byte translation (cross-impl)', () =>
226
112
  expect(Buffer.from(cs.senderChain.senderRatchetKey).equals(ephPub)).toBe(true);
227
113
  expect(Buffer.from(cs.senderChain.senderRatchetKeyPrivate).equals(ephPriv)).toBe(true);
228
114
  });
229
- test('round-trip: bridge SET → upstream loadSession+haveOpenSession → still open + correct identity', async () => {
115
+ test('round-trip: bridge SET → upstream loadSession → still open + same identity', async () => {
230
116
  const { wrapped, keys } = await makeWrapped();
231
117
  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();
118
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, buildBridgeSessionBytes({ remoteIdentity: new Uint8Array(remoteIdentity), remoteRegistrationId: 7 }));
235
119
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
236
- const rec = SessionRecord.deserialize(stored);
237
- expect(rec.haveOpenSession()).toBe(true);
120
+ const rec = SessionRecord.deserialize(keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN]);
238
121
  const open = rec.getOpenSession();
239
122
  expect(open.registrationId).toBe(7);
240
123
  expect(Buffer.from(open.indexInfo.remoteIdentityKey).equals(remoteIdentity)).toBe(true);
241
124
  });
242
125
  });
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.
126
+ describe('wrap-legacy-store: session edge cases', () => {
127
+ test('lastRemoteEphemeralKey does NOT collide with senderRatchetKey when receiverChains empty', async () => {
128
+ // Regression: the converter previously used senderRatchetKey as a
129
+ // placeholder, which made upstream's `maybeStepRatchet` close the
130
+ // SENDER chain on the next peer ratchet (corrupting outbound
131
+ // encryption). Now uses aliceBaseKey (when bob) or empty buffer.
251
132
  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
133
  const senderRatchetPub = fill(33, 2);
256
134
  const aliceBaseKey = fill(33, 7);
257
135
  const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
@@ -274,56 +152,18 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
274
152
  }),
275
153
  previousSessions: []
276
154
  })).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();
155
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, protoBytes);
156
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
280
157
  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
158
  expect(entry.currentRatchet.lastRemoteEphemeralKey).not.toBe(Buffer.from(senderRatchetPub).toString('base64'));
285
159
  });
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({
296
- sessionVersion: 3,
297
- rootKey: fill(32, 1),
298
- previousCounter: 0,
299
- senderChain: {
300
- senderRatchetKey: fill(33, 2),
301
- senderRatchetKeyPrivate: fill(32, 3),
302
- chainKey: { index: 0, key: fill(32, 4) },
303
- messageKeys: []
304
- },
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({
160
+ test('baseKeyType: pendingPreKey present → OURS (alice), absent → THEIRS (bob)', async () => {
161
+ // Regression: previously set OURS whenever aliceBaseKey existed
162
+ // (always), making upstream `getSession(byBaseKey)` throw.
163
+ const baseKeyTypeFor = async (withPendingPreKey) => {
164
+ const { wrapped, keys } = await makeWrapped();
165
+ const aliceBaseKey = fill(33, 7);
166
+ const session = bridgeProto.SessionStructure.create({
327
167
  sessionVersion: 3,
328
168
  rootKey: fill(32, 1),
329
169
  previousCounter: 0,
@@ -333,24 +173,28 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
333
173
  chainKey: { index: 0, key: fill(32, 4) },
334
174
  messageKeys: []
335
175
  },
336
- receiverChains: [],
176
+ receiverChains: withPendingPreKey
177
+ ? []
178
+ : [
179
+ {
180
+ senderRatchetKey: fill(33, 5),
181
+ chainKey: { index: 1, key: fill(32, 6) },
182
+ messageKeys: []
183
+ }
184
+ ],
337
185
  remoteIdentityPublic: fill(33, 8),
338
186
  remoteRegistrationId: 1,
339
187
  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
188
+ ...(withPendingPreKey ? { pendingPreKey: { preKeyId: 1, signedPreKeyId: 2, baseKey: aliceBaseKey } } : {})
189
+ });
190
+ await wrapped.set('session', BRIDGE_SESSION_KEY_PN, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({ currentSession: session, previousSessions: [] })).finish());
191
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_PN];
192
+ return Object.values(stored._sessions)[0].indexInfo.baseKeyType;
193
+ };
194
+ expect(await baseKeyTypeFor(true)).toBe(1); // OURS
195
+ expect(await baseKeyTypeFor(false)).toBe(2); // THEIRS
348
196
  });
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.
197
+ test('multi-session: currentSession + previousSessions[] all land in upstream `_sessions`', async () => {
354
198
  const { wrapped, keys } = await makeWrapped();
355
199
  const mkSession = (rootSeed, baseKeySeed) => bridgeProto.SessionStructure.create({
356
200
  sessionVersion: 3,
@@ -373,38 +217,28 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
373
217
  remoteRegistrationId: 1,
374
218
  aliceBaseKey: fill(33, baseKeySeed)
375
219
  });
376
- const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
220
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
377
221
  currentSession: mkSession(1, 100),
378
222
  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];
223
+ })).finish());
224
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
382
225
  expect(stored.version).toBe('v1');
383
- // 1 current + 2 previous, distinct baseKeys → 3 entries.
384
226
  expect(Object.keys(stored._sessions).length).toBe(3);
385
- // Exactly one OPEN session (closed=-1).
386
227
  const open = Object.values(stored._sessions).filter(e => e.indexInfo.closed === -1);
387
228
  expect(open.length).toBe(1);
388
229
  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).
230
+ // Front of Rust's previous_sessions = newest archived must get
231
+ // the LARGER `closed` timestamp so upstream's removeOldSessions
232
+ // evicts the oldest first.
393
233
  const closedEntries = Object.values(stored._sessions).filter(e => e.indexInfo.closed > 0);
394
- expect(closedEntries.length).toBe(2);
395
234
  const newer = closedEntries.find(e => e.indexInfo.baseKey === Buffer.from(fill(33, 200)).toString('base64'));
396
235
  const older = closedEntries.find(e => e.indexInfo.baseKey === Buffer.from(fill(33, 250)).toString('base64'));
397
- expect(newer).toBeDefined();
398
- expect(older).toBeDefined();
399
236
  expect(newer.indexInfo.closed).toBeGreaterThan(older.indexInfo.closed);
400
237
  });
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`)
238
+ test('pendingPreKey round-trips with field rename `signedPreKeyId↔signedKeyId`', async () => {
405
239
  const { wrapped, keys } = await makeWrapped();
406
240
  const aliceBaseKey = fill(33, 7);
407
- const protoBytes = bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
241
+ await wrapped.set('session', BRIDGE_SESSION_KEY_LID, bridgeProto.RecordStructure.encode(bridgeProto.RecordStructure.create({
408
242
  currentSession: bridgeProto.SessionStructure.create({
409
243
  sessionVersion: 3,
410
244
  rootKey: fill(32, 1),
@@ -422,46 +256,36 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
422
256
  pendingPreKey: { preKeyId: 314, signedPreKeyId: 271, baseKey: aliceBaseKey }
423
257
  }),
424
258
  previousSessions: []
425
- })).finish();
426
- await wrapped.set('session', SAMPLE_BRIDGE_KEY_LID, protoBytes);
427
- const stored = keys.raw['session']?.[EXPECTED_UPSTREAM_KEY_LID];
259
+ })).finish());
260
+ const stored = keys.raw['session']?.[UPSTREAM_SESSION_KEY_LID];
428
261
  const entry = Object.values(stored._sessions)[0];
429
- expect(entry.pendingPreKey).toBeDefined();
430
262
  expect(entry.pendingPreKey.preKeyId).toBe(314);
431
- // Field rename: bridge's `signedPreKeyId`upstream's `signedKeyId`.
432
- expect(entry.pendingPreKey.signedKeyId).toBe(271);
263
+ expect(entry.pendingPreKey.signedKeyId).toBe(271); // rename: signedPreKeyId → signedKeyId
433
264
  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));
265
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
437
266
  const decoded = bridgeProto.RecordStructure.decode(protoOut);
438
267
  expect(decoded.currentSession?.pendingPreKey?.preKeyId).toBe(314);
439
268
  expect(decoded.currentSession?.pendingPreKey?.signedPreKeyId).toBe(271);
440
269
  });
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.
270
+ test('aliceBaseKey preserved on JS→bridge regardless of baseKeyType (bob-side restoration)', async () => {
271
+ // Regression: previously dropped aliceBaseKey when baseKeyType=THEIRS,
272
+ // breaking Rust's `find_matching_previous_session_index` lookup on
273
+ // subsequent PreKeySignal arrivals.
447
274
  const { wrapped, keys } = await makeWrapped();
448
275
  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
276
  keys.raw['session'] = {
456
- [EXPECTED_UPSTREAM_KEY_LID]: {
277
+ [UPSTREAM_SESSION_KEY_LID]: {
457
278
  _sessions: {
458
279
  [baseKey.toString('base64')]: {
459
280
  registrationId: 4242,
460
281
  currentRatchet: {
461
- ephemeralKeyPair: { pubKey: ephPub.toString('base64'), privKey: ephPriv.toString('base64') },
462
- lastRemoteEphemeralKey: lastRemoteEph.toString('base64'),
282
+ ephemeralKeyPair: {
283
+ pubKey: Buffer.alloc(33, 0xd0).toString('base64'),
284
+ privKey: Buffer.alloc(32, 0xe0).toString('base64')
285
+ },
286
+ lastRemoteEphemeralKey: Buffer.alloc(33, 0xf0).toString('base64'),
463
287
  previousCounter: 0,
464
- rootKey: rootKey.toString('base64')
288
+ rootKey: Buffer.alloc(32, 0xb0).toString('base64')
465
289
  },
466
290
  indexInfo: {
467
291
  baseKey: baseKey.toString('base64'),
@@ -469,7 +293,7 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
469
293
  closed: -1,
470
294
  used: Date.now(),
471
295
  created: Date.now(),
472
- remoteIdentityKey: remoteIdent.toString('base64')
296
+ remoteIdentityKey: Buffer.alloc(33, 0xc0).toString('base64')
473
297
  },
474
298
  _chains: {}
475
299
  }
@@ -477,11 +301,8 @@ describe('wrap-legacy-store: session edge cases (multi-session, pendingPreKey, l
477
301
  version: 'v1'
478
302
  }
479
303
  };
480
- const protoOut = (await wrapped.get('session', SAMPLE_BRIDGE_KEY_LID));
304
+ const protoOut = (await wrapped.get('session', BRIDGE_SESSION_KEY_LID));
481
305
  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
306
  expect(Buffer.from(decoded.currentSession.aliceBaseKey).equals(baseKey)).toBe(true);
486
307
  });
487
308
  });