@nexustechpro/baileys 2.1.2 → 2.1.6

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.
@@ -8,8 +8,9 @@ import {
8
8
  SenderKeyName,
9
9
  SenderKeyDistributionMessage,
10
10
  } from 'whatsapp-rust-bridge'
11
+ import { proto } from '../../WAProto/index.js'
11
12
  import { LRUCache } from 'lru-cache'
12
- import { generateSignalPubKey, migrateIndexKey } from '../Utils/index.js'
13
+ import { generateSignalPubKey } from '../Utils/index.js'
13
14
  import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser, jidDecode, transferDevice, WAJIDDomains } from '../WABinary/index.js'
14
15
  import { LIDMappingStore } from './lid-mapping.js'
15
16
 
@@ -27,8 +28,6 @@ const jidToAddr = (jid) => {
27
28
 
28
29
  const jidToSenderKeyName = (group, user) => new SenderKeyName(group, jidToAddr(user))
29
30
 
30
- const v2Key = (addr) => `${addr}:v2`
31
-
32
31
  // ─── Buffer Utils ─────────────────────────────────────────────────────────────
33
32
 
34
33
  const toBuffer = (raw) => {
@@ -62,31 +61,19 @@ const isOldJson = (raw) => {
62
61
  const bufEqual = (a, b) =>
63
62
  a && b && a.length === b.length && a.every((byte, i) => byte === b[i])
64
63
 
65
- // ─── Identity Extraction ──────────────────────────────────────────────────────
66
- // Reads field 4 (identity key, 33 bytes) from PreKeyWhisperMessage protobuf envelope
64
+ // ─── Identity Key Extraction ──────────────────────────────────────────────────
67
65
 
68
66
  const extractIdentityFromPkmsg = (ciphertext) => {
69
67
  try {
70
68
  if (!ciphertext || ciphertext.length < 2) return undefined
71
69
  if ((ciphertext[0] & 0xf) !== 3) return undefined
72
- const buf = ciphertext.slice(1)
73
- let i = 0
74
- while (i < buf.length) {
75
- const tag = buf[i++]
76
- const fieldNum = tag >> 3
77
- const wireType = tag & 0x7
78
- if (wireType === 2) {
79
- let len = 0, shift = 0
80
- while (i < buf.length) { const b = buf[i++]; len |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7 }
81
- if (fieldNum === 4 && len === 33) return new Uint8Array(buf.slice(i, i + len))
82
- i += len
83
- } else if (wireType === 0) { while (i < buf.length && buf[i++] & 0x80) { } }
84
- else if (wireType === 5) { i += 4 }
85
- else if (wireType === 1) { i += 8 }
86
- else break
87
- }
88
- } catch { }
89
- return undefined
70
+ const decoded = proto.PreKeySignalMessage.decode(ciphertext.slice(1))
71
+ const key = decoded.identityKey
72
+ if (key && key.length === 33) return new Uint8Array(key)
73
+ return undefined
74
+ } catch {
75
+ return undefined
76
+ }
90
77
  }
91
78
 
92
79
  // ─── Main Factory ─────────────────────────────────────────────────────────────
@@ -95,29 +82,29 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
95
82
  const lidMapping = new LIDMappingStore(auth.keys, logger, pnToLIDFunc)
96
83
  const parsedKeys = auth.keys
97
84
 
98
- // #17 expose lidCache ref so migrateSession can invalidate it
85
+ // LRU cache for PN→LID address resolution to avoid repeated DB lookups
99
86
  const lidCache = new LRUCache({ max: 500, ttl: 5 * 60 * 1000 })
100
87
 
101
- // #1 dedicated mutex serialising all session index writes to prevent cross-JID race
102
- let sessionIndexWriteLock = Promise.resolve()
103
- const withSessionLock = (fn) => {
104
- const next = sessionIndexWriteLock.then(fn).catch(fn)
105
- sessionIndexWriteLock = next.then(() => { }, () => { })
106
- return next
107
- }
108
-
109
- // #2 — session read cache: avoids full index read on every decrypt
88
+ // Per-address session read cache: avoids a DB round-trip on every decrypt.
89
+ // Keyed by resolved LID addr string. Value is Uint8Array | null.
110
90
  const sessionReadCache = new LRUCache({ max: 1000, ttl: 5 * 60 * 1000, ttlAutopurge: true })
111
91
 
112
- const storage = signalStorage(auth, lidMapping, logger, lidCache, sessionReadCache, withSessionLock)
92
+ const storage = signalStorage(auth, lidMapping, logger, lidCache, sessionReadCache)
93
+
94
+ // Tracks which PN→LID session migrations have already been done this process
95
+ // lifetime so migrateSession is idempotent even if called repeatedly.
113
96
  const migratedCache = new LRUCache({ ttl: 7 * 24 * 60 * 60 * 1000, ttlAutopurge: true, updateAgeOnGet: true })
97
+
114
98
  const txn = (fn, key) => parsedKeys.transaction(fn, key)
115
99
 
116
100
  return {
117
- // ── Group ─────────────────────────────────────────────────────────────────
101
+ // ── Group messaging ───────────────────────────────────────────────────────
118
102
 
119
103
  decryptGroupMessage({ group, authorJid, msg }) {
120
- return txn(() => new GroupCipher(storage, group, jidToAddr(authorJid)).decrypt(msg), group)
104
+ return txn(
105
+ () => new GroupCipher(storage, group, jidToAddr(authorJid)).decrypt(toU8(msg)),
106
+ group
107
+ )
121
108
  },
122
109
 
123
110
  async processSenderKeyDistributionMessage({ item, authorJid }) {
@@ -126,7 +113,6 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
126
113
  const senderMsg = SenderKeyDistributionMessage.deserialize(
127
114
  toU8(item.axolotlSenderKeyDistributionMessage)
128
115
  )
129
- // do NOT pre-store a blank SenderKeyRecord — serialize() returns 0 bytes and bridge throws
130
116
  return txn(() => new GroupSessionBuilder(storage).process(senderName, senderMsg), item.groupId)
131
117
  },
132
118
 
@@ -139,7 +125,6 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
139
125
  }, group)
140
126
  },
141
127
 
142
- // #10 — kept for API completeness but not called by messages-send.js
143
128
  getSenderKeyDistributionMessage({ group, meId }) {
144
129
  return txn(async () => {
145
130
  const senderName = jidToSenderKeyName(group, meId)
@@ -149,15 +134,21 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
149
134
 
150
135
  async hasSenderKey({ group, meId }) {
151
136
  const name = jidToSenderKeyName(group, meId).toString()
152
- const { [name]: key } = await parsedKeys.get('sender-key', [name])
153
- return !!toBuffer(key)
137
+ const { [name]: raw } = await parsedKeys.get('sender-key', [name])
138
+ const buf = toU8(raw)
139
+ if (!buf) return false
140
+ try {
141
+ return !SenderKeyRecord.deserialize(buf).isEmpty()
142
+ } catch {
143
+ return false
144
+ }
154
145
  },
155
146
 
156
147
  deleteSenderKey(group, authorJid) {
157
148
  return parsedKeys.set({ 'sender-key': { [jidToSenderKeyName(group, authorJid).toString()]: null } })
158
149
  },
159
150
 
160
- // ── 1:1 ───────────────────────────────────────────────────────────────────
151
+ // ── 1:1 messaging ─────────────────────────────────────────────────────────
161
152
 
162
153
  async decryptMessage({ jid, type, ciphertext }) {
163
154
  const addr = jidToAddr(jid)
@@ -166,26 +157,26 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
166
157
 
167
158
  try {
168
159
  return await txn(async () => {
169
- // #8 — identity save inside transaction to prevent concurrent pkmsg race
170
160
  if (type === 'pkmsg') {
171
161
  const identityKey = extractIdentityFromPkmsg(ciphertext)
172
162
  if (identityKey) {
173
163
  const changed = await storage.saveIdentity(addrStr, identityKey)
174
- if (changed) logger?.info?.({ jid }, '[Signal] Identity key changed, session cleared')
164
+ if (changed) logger?.info?.({ jid }, '[Signal] Identity key changed session cleared for re-handshake')
175
165
  } else {
176
- logger?.debug?.({ jid }, '[Signal] pkmsg: could not extract identity key from envelope')
166
+ logger?.warn?.({ jid }, '[Signal] pkmsg: could not extract identity key from envelope')
177
167
  }
178
- return cipher.decryptPreKeyWhisperMessage(ciphertext)
168
+ return cipher.decryptPreKeyWhisperMessage(toU8(ciphertext))
179
169
  }
180
- if (type === 'msg') return cipher.decryptWhisperMessage(ciphertext)
181
- throw new Error(`Unknown message type: ${type}`)
170
+ if (type === 'msg') {
171
+ return cipher.decryptWhisperMessage(toU8(ciphertext))
172
+ }
173
+ throw new Error(`[Signal] Unknown message type: ${type}`)
182
174
  }, jid)
183
175
  } catch (e) {
184
176
  if (e?.message?.includes('DuplicatedMessage')) {
185
177
  logger?.debug?.({ jid }, '[Signal] Duplicate message ignored')
186
178
  return null
187
179
  }
188
- // #11 — on untrusted identity / session corruption, wipe and let caller retry
189
180
  if (e?.message?.includes('UntrustedIdentity') || e?.message?.includes('InvalidMessage')) {
190
181
  logger?.warn?.({ jid, err: e.message }, '[Signal] Session error — wiping session for re-handshake')
191
182
  await storage.wipeSession(addrStr)
@@ -197,7 +188,7 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
197
188
 
198
189
  encryptMessage({ jid, data }) {
199
190
  return txn(async () => {
200
- const { type: sigType, body } = await new SessionCipher(storage, jidToAddr(jid)).encrypt(data)
191
+ const { type: sigType, body } = await new SessionCipher(storage, jidToAddr(jid)).encrypt(toU8(data))
201
192
  return { type: sigType === 3 ? 'pkmsg' : 'msg', ciphertext: Buffer.from(body) }
202
193
  }, jid)
203
194
  },
@@ -206,181 +197,106 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
206
197
  return txn(() => new SessionBuilder(storage, jidToAddr(jid)).processPreKeyBundle(session), jid)
207
198
  },
208
199
 
209
- // ── Session management ────────────────────────────────────────────────────
200
+ // ── Session utilities ──────────────────────────────────────────────────────
210
201
 
211
- jidToSignalProtocolAddress: jid => jidToAddr(jid).toString(),
202
+ jidToSignalProtocolAddress: (jid) => jidToAddr(jid).toString(),
212
203
 
213
204
  lidMapping,
214
205
 
215
206
  async validateSession(jid) {
216
207
  try {
217
208
  const addr = jidToAddr(jid).toString()
218
- const batch = await migrateIndexKey(parsedKeys, 'session')
219
- const raw = toBuffer(batch[v2Key(addr)]) || toBuffer(batch[addr])
220
- if (!raw || isOldJson(raw)) return { exists: false, reason: 'no session' }
221
- return SessionRecord.deserialize(raw).haveOpenSession()
222
- ? { exists: true }
223
- : { exists: false, reason: 'no open session' }
224
- } catch { return { exists: false, reason: 'error' } }
209
+ const raw = await storage.loadSession(addr)
210
+ if (!raw) return { exists: false, reason: 'no session' }
211
+ try {
212
+ return SessionRecord.deserialize(raw).haveOpenSession()
213
+ ? { exists: true }
214
+ : { exists: false, reason: 'no open session' }
215
+ } catch {
216
+ return { exists: false, reason: 'deserialize error' }
217
+ }
218
+ } catch {
219
+ return { exists: false, reason: 'error' }
220
+ }
225
221
  },
226
222
 
227
223
  async deleteSession(jids) {
228
224
  if (!jids?.length) return
229
225
  return txn(async () => {
230
- // #9 spread before mutating to avoid corrupting in-memory store on write failure
231
- const batch = await migrateIndexKey(parsedKeys, 'session')
232
- const updated = { ...batch }
226
+ const updates = {}
233
227
  for (const jid of jids) {
234
228
  const addr = jidToAddr(jid).toString()
235
- delete updated[addr]
236
- delete updated[v2Key(addr)]
229
+ updates[addr] = null
237
230
  sessionReadCache.delete(addr)
238
231
  }
239
- await parsedKeys.set({ session: { index: updated } })
232
+ await parsedKeys.set({ session: updates })
240
233
  }, `del-${jids.length}`)
241
234
  },
242
235
 
243
- // ── Session migration ─────────────────────────────────────────────────────
236
+ // ── Session migration (PN → LID) ──────────────────────────────────────────
244
237
 
245
238
  async migrateSession(fromJid, toJid) {
246
239
  if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 }
247
240
  if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) return { migrated: 0, skipped: 0, total: 1 }
248
241
  const { user } = jidDecode(fromJid)
249
242
 
250
- // #15 parallel fetch of device-list and session index
251
- const [deviceListBatch, sessionBatch] = await Promise.all([
252
- migrateIndexKey(parsedKeys, 'device-list'),
253
- migrateIndexKey(parsedKeys, 'session'),
254
- ])
255
-
256
- const userDevices = deviceListBatch[user] ? [...deviceListBatch[user]] : []
243
+ const { [user]: userDevices_ } = await parsedKeys.get('device-list', [user])
244
+ const userDevices = userDevices_ ? [...userDevices_] : []
257
245
  const fromDeviceStr = jidDecode(fromJid).device?.toString() || '0'
258
246
  if (!userDevices.includes(fromDeviceStr)) userDevices.push(fromDeviceStr)
259
247
 
260
- const deviceJids = userDevices
248
+ // Build addr strings for each device, skip already-migrated
249
+ const candidates = userDevices
261
250
  .filter(d => !migratedCache.has(`${user}.${d}`))
262
251
  .map(d => {
263
252
  const num = parseInt(d)
264
- return {
265
- cacheKey: `${user}.${d}`,
266
- jid: num === 99 ? `${user}:99@hosted` : num === 0 ? `${user}@s.whatsapp.net` : `${user}:${num}@s.whatsapp.net`
267
- }
268
- })
269
- .filter(({ jid }) => {
270
- const addr = jidToAddr(jid).toString()
271
- return sessionBatch[v2Key(addr)] || sessionBatch[addr]
253
+ const jid = num === 99 ? `${user}:99@hosted`
254
+ : num === 0 ? `${user}@s.whatsapp.net`
255
+ : `${user}:${num}@s.whatsapp.net`
256
+ return { cacheKey: `${user}.${d}`, jid, addr: jidToAddr(jid).toString() }
272
257
  })
273
258
 
274
- if (!deviceJids.length) return { migrated: 0, skipped: 0, total: 0 }
275
-
276
- return txn(async () => {
277
- // #5 — re-read inside txn for a fresh snapshot
278
- const freshBatch = await migrateIndexKey(parsedKeys, 'session')
279
- const updated = { ...freshBatch }
280
- let migrated = 0
281
-
282
- for (const { jid, cacheKey } of deviceJids) {
283
- const pnAddr = jidToAddr(jid).toString()
284
- const lidAddr = jidToAddr(transferDevice(jid, toJid)).toString()
285
- const raw = toBuffer(updated[v2Key(pnAddr)]) || toBuffer(updated[pnAddr])
286
- if (!raw || isOldJson(raw)) continue
287
- const sess = SessionRecord.deserialize(raw)
288
- if (!sess.haveOpenSession()) continue
289
- updated[v2Key(lidAddr)] = sess.serialize()
290
- updated[lidAddr] = { version: 'v1', _sessions: {} }
291
- delete updated[v2Key(pnAddr)]
292
- delete updated[pnAddr]
293
- // #3 — invalidate lidCache for migrated PN addr so next resolve hits store
294
- lidCache.delete(pnAddr)
295
- sessionReadCache.delete(pnAddr)
296
- migrated++
297
- migratedCache.set(cacheKey, true)
298
- }
259
+ if (!candidates.length) return { migrated: 0, skipped: 0, total: 0 }
299
260
 
300
- if (migrated > 0) await parsedKeys.set({ session: { index: updated } })
301
- return { migrated, skipped: deviceJids.length - migrated, total: deviceJids.length }
302
- }, `migrate-${jidDecode(toJid)?.user}`)
303
- },
261
+ // Bulk-fetch only the session keys we actually need
262
+ const addrs = candidates.map(c => c.addr)
263
+ const existing = await parsedKeys.get('session', addrs)
304
264
 
305
- async migrateAllPNSessionsToLID() {
306
- // #16 skip everything if creds have no LID at all
307
- if (!auth.creds?.me?.lid) return 0
308
-
309
- // lid-mapping read outside txn to avoid nested key-namespace lock
310
- // #5 — session batch read outside is intentional; re-read inside txn before write
311
- const [sessionBatch, stored] = await (async () => {
312
- const sb = await migrateIndexKey(parsedKeys, 'session')
313
- const sessionKeys = Object.keys(sb)
314
- if (!sessionKeys.length) return [sb, {}]
315
- const pnAddrs = sessionKeys.filter(addr => {
316
- if (addr.endsWith(':v2') || !addr.includes('.')) return false
317
- const [, dt] = addr.split('.')[0].split('_')
318
- const domainType = parseInt(dt || '0')
319
- return domainType === WAJIDDomains.WHATSAPP || domainType === WAJIDDomains.HOSTED
320
- })
321
- if (!pnAddrs.length) return [sb, {}]
322
- const pnUserSet = new Set(pnAddrs.map(addr => addr.split('.')[0].split('_')[0]))
323
- const s = await parsedKeys.get('lid-mapping', [...pnUserSet])
324
- return [sb, s]
325
- })()
326
-
327
- const sessionKeys = Object.keys(sessionBatch)
328
- if (!sessionKeys.length) return 0
329
-
330
- const pnAddrs = sessionKeys.filter(addr => {
331
- if (addr.endsWith(':v2') || !addr.includes('.')) return false
332
- const [, dt] = addr.split('.')[0].split('_')
333
- const domainType = parseInt(dt || '0')
334
- return domainType === WAJIDDomains.WHATSAPP || domainType === WAJIDDomains.HOSTED
265
+ const toMigrate = candidates.filter(c => {
266
+ const raw = toU8(existing[c.addr])
267
+ return raw && !isOldJson(raw)
335
268
  })
336
- if (!pnAddrs.length) return 0
337
269
 
338
- const pnToLidUserMap = new Map()
339
- for (const pnUser of new Set(pnAddrs.map(addr => addr.split('.')[0].split('_')[0]))) {
340
- const lidUser = stored[pnUser]
341
- if (lidUser && typeof lidUser === 'string') pnToLidUserMap.set(pnUser, lidUser)
342
- }
343
- if (!pnToLidUserMap.size) return 0
270
+ if (!toMigrate.length) return { migrated: 0, skipped: candidates.length, total: candidates.length }
344
271
 
345
272
  return txn(async () => {
346
- // #5 — fresh read inside txn before writing
347
- const freshBatch = await migrateIndexKey(parsedKeys, 'session')
348
- const updated = { ...freshBatch }
273
+ // Re-fetch inside txn for freshness
274
+ const fresh = await parsedKeys.get('session', toMigrate.map(c => c.addr))
275
+ const updates = {}
349
276
  let migrated = 0
350
277
 
351
- for (const addr of pnAddrs) {
352
- const [deviceId, device] = addr.split('.')
353
- const [user, dt] = deviceId.split('_')
354
- const domainType = parseInt(dt || '0')
355
- const lidUser = pnToLidUserMap.get(user)
356
- if (!lidUser) continue
357
- const lidDomainType = domainType === WAJIDDomains.HOSTED ? WAJIDDomains.HOSTED_LID : WAJIDDomains.LID
358
- const lidAddr = `${lidUser}_${lidDomainType}.${device}`
359
- if (updated[v2Key(lidAddr)]) continue
360
- const raw = toBuffer(updated[v2Key(addr)]) || toBuffer(updated[addr])
278
+ for (const { jid, addr, cacheKey } of toMigrate) {
279
+ const raw = toU8(fresh[addr])
361
280
  if (!raw || isOldJson(raw)) continue
362
- const sess = SessionRecord.deserialize(raw)
281
+ let sess
282
+ try { sess = SessionRecord.deserialize(raw) } catch { continue }
363
283
  if (!sess.haveOpenSession()) continue
364
- updated[v2Key(lidAddr)] = sess.serialize()
365
- updated[lidAddr] = { version: 'v1', _sessions: {} }
366
- delete updated[v2Key(addr)]
367
- delete updated[addr]
368
- // #3 — invalidate caches for migrated addresses
284
+ const lidAddr = jidToAddr(transferDevice(jid, toJid)).toString()
285
+ updates[lidAddr] = sess.serialize()
286
+ updates[addr] = null
369
287
  lidCache.delete(addr)
370
288
  sessionReadCache.delete(addr)
371
289
  migrated++
372
- migratedCache.set(`${user}.${device}`, true)
290
+ migratedCache.set(cacheKey, true)
373
291
  }
374
292
 
375
293
  if (migrated > 0) {
376
- await parsedKeys.set({ session: { index: updated } })
377
- logger?.info?.({ migrated, totalPN: pnAddrs.length, mappingsFound: pnToLidUserMap.size }, '[Signal] Batch-migrated PN sessions to LID on connect')
294
+ await parsedKeys.set({ session: updates })
378
295
  }
379
- return migrated
380
- }, 'migrate-all-pn-to-lid')
296
+ return { migrated, skipped: toMigrate.length - migrated, total: candidates.length }
297
+ }, `migrate-${jidDecode(toJid)?.user}`)
381
298
  },
382
299
 
383
- // #17 — warm the LID cache on connect from stored mappings
384
300
  async warmLIDCache(mappings) {
385
301
  for (const { pn, lid } of mappings) {
386
302
  try {
@@ -401,16 +317,16 @@ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
401
317
  }
402
318
 
403
319
  // ─── Storage Adapter ──────────────────────────────────────────────────────────
404
- // Implements SignalStorage interface for whatsapp-rust-bridge.
320
+ // Implements the SignalStorage interface consumed by whatsapp-rust-bridge WASM.
405
321
  //
406
- // Session index dual-key pattern:
407
- // v2Key(addr)actual binary SessionRecord bytes
408
- // addr → JSON tombstone { version:'v1', _sessions:{} } for old-code compat
409
- //
410
- // storeSenderKey always receives plain Uint8Array from bridge (confirmed by unit test).
411
- // NEVER pre-store a blank SenderKeyRecord — serialize() returns 0 bytes, bridge throws.
322
+ // One key per record — exactly as original Baileys does:
323
+ // session: addr string Uint8Array (SessionRecord.serialize())
324
+ // identity-key: addr string → Uint8Array (33-byte identity key)
325
+ // pre-key: id string → { public, private }
326
+ // sender-key: keyId string Buffer
327
+
328
+ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionReadCache) {
412
329
 
413
- function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionReadCache, withSessionLock) {
414
330
  const resolveLID = async (id) => {
415
331
  if (!id.includes('.')) return id
416
332
  const cached = lidCache.get(id)
@@ -426,77 +342,48 @@ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionRea
426
342
  return result
427
343
  }
428
344
 
429
- // #1/#2 — all index reads go through the read cache; all writes go through the write lock
430
- const getIndex = async () => {
431
- const cached = sessionReadCache.get('__index__')
432
- if (cached) return cached
433
- const batch = await migrateIndexKey(keys, 'session')
434
- sessionReadCache.set('__index__', batch)
435
- return batch
436
- }
437
-
438
- const setIndex = (batch) => withSessionLock(async () => {
439
- sessionReadCache.set('__index__', batch)
440
- try {
441
- await keys.set({ session: { index: batch } })
442
- } catch (e) {
443
- // #18 — on write failure invalidate cache so next read hits store
444
- sessionReadCache.delete('__index__')
445
- logger?.error?.(`[Signal] storeSession write failed: ${e.message}`)
446
- throw e
447
- }
448
- })
449
-
450
345
  return {
346
+ // ── Sessions ─────────────────────────────────────────────────────────────
347
+
451
348
  loadSession: async (id) => {
452
349
  try {
453
350
  const addr = await resolveLID(id)
454
- // #2 — check per-addr cache before reading full index
455
351
  const cached = sessionReadCache.get(addr)
456
352
  if (cached !== undefined) return cached === null ? null : toU8(cached)
457
- const batch = await getIndex()
458
- const v2 = batch[v2Key(addr)]
459
- if (v2) {
460
- if (isOldJson(v2)) { logger?.debug?.(`[Signal] Corrupt v2 for ${addr}, fresh handshake`); sessionReadCache.set(addr, null); return null }
461
- const buf = toU8(v2)
462
- if (buf) { sessionReadCache.set(addr, buf); return buf }
463
- }
464
- const plain = batch[addr]
465
- if (!plain || isOldJson(plain)) {
466
- if (plain) logger?.debug?.(`[Signal] Old JSON session for ${addr}, fresh handshake`)
353
+ const { [addr]: raw } = await keys.get('session', [addr])
354
+ if (!raw || isOldJson(raw)) {
467
355
  sessionReadCache.set(addr, null)
468
356
  return null
469
357
  }
470
- const buf = toU8(plain)
358
+ const buf = toU8(raw)
471
359
  sessionReadCache.set(addr, buf)
472
360
  return buf
473
- } catch (e) { logger?.error?.(`[Signal] loadSession error: ${e.message}`); return null }
361
+ } catch (e) {
362
+ logger?.error?.(`[Signal] loadSession error for ${id}: ${e.message}`)
363
+ return null
364
+ }
474
365
  },
475
366
 
476
367
  storeSession: async (id, record) => {
477
368
  const addr = await resolveLID(id)
478
369
  const serialized = record.serialize()
479
- // #19 — only update tombstone on first write; v2 always updated
480
- const batch = await getIndex()
481
- const needsTombstone = !batch[addr] || !isOldJson(batch[addr])
482
- const updated = {
483
- ...batch,
484
- [v2Key(addr)]: serialized,
485
- ...(needsTombstone ? { [addr]: { version: 'v1', _sessions: {} } } : {}),
486
- }
487
370
  sessionReadCache.set(addr, toU8(serialized))
488
- await setIndex(updated)
371
+ await keys.set({ session: { [addr]: serialized } })
489
372
  },
490
373
 
491
- // #7 proper identity verification instead of always trusting
374
+ // ── Identity keys ─────────────────────────────────────────────────────────
375
+
492
376
  isTrustedIdentity: async (id, identityKey) => {
493
377
  try {
494
378
  const addr = await resolveLID(id)
495
- const { [addr]: raw } = await keys.get('identity-key', [addr])
496
- const existing = toU8(raw)
379
+ const { [addr]: existing } = await keys.get('identity-key', [addr])
497
380
  if (!existing) return true
498
- return !!bufEqual(existing, identityKey instanceof Uint8Array ? identityKey : toU8(identityKey))
499
- } catch { return true }
381
+ const a = toU8(existing)
382
+ const b = identityKey instanceof Uint8Array ? identityKey : toU8(identityKey)
383
+ return !!bufEqual(a, b)
384
+ } catch {
385
+ return true
386
+ }
500
387
  },
501
388
 
502
389
  loadIdentityKey: async (id) => {
@@ -507,37 +394,33 @@ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionRea
507
394
 
508
395
  saveIdentity: async (id, identityKey) => {
509
396
  const addr = await resolveLID(id)
510
- const { [addr]: raw } = await keys.get('identity-key', [addr])
511
- const existing = toU8(raw)
512
- if (existing && !bufEqual(existing, identityKey)) {
513
- const batch = await getIndex()
514
- const updated = { ...batch }
515
- delete updated[addr]
516
- delete updated[v2Key(addr)]
397
+ const { [addr]: existing } = await keys.get('identity-key', [addr])
398
+ const a = toU8(existing)
399
+ const b = identityKey instanceof Uint8Array ? identityKey : toU8(identityKey)
400
+ if (a && !bufEqual(a, b)) {
401
+ // Identity changed wipe session and update key atomically
517
402
  sessionReadCache.delete(addr)
518
- sessionReadCache.delete('__index__')
519
- await setIndex(updated)
520
- await keys.set({ 'identity-key': { [addr]: identityKey } })
521
403
  lidCache.delete(id)
404
+ await keys.set({
405
+ session: { [addr]: null },
406
+ 'identity-key': { [addr]: b }
407
+ })
522
408
  return true
523
409
  }
524
- if (!existing) {
525
- await keys.set({ 'identity-key': { [addr]: identityKey } })
410
+ if (!a) {
411
+ await keys.set({ 'identity-key': { [addr]: b } })
526
412
  return true
527
413
  }
528
414
  return false
529
415
  },
530
416
 
531
- // #11 — exposed for decryptMessage error recovery
532
417
  wipeSession: async (addr) => {
533
- const batch = await getIndex()
534
- const updated = { ...batch }
535
- delete updated[addr]
536
- delete updated[v2Key(addr)]
537
418
  sessionReadCache.delete(addr)
538
- await setIndex(updated)
419
+ await keys.set({ session: { [addr]: null } })
539
420
  },
540
421
 
422
+ // ── PreKeys ───────────────────────────────────────────────────────────────
423
+
541
424
  loadPreKey: async (id) => {
542
425
  const { [id.toString()]: key } = await keys.get('pre-key', [id.toString()])
543
426
  if (!key) return null
@@ -549,7 +432,6 @@ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionRea
549
432
 
550
433
  removePreKey: (id) => keys.set({ 'pre-key': { [id]: null } }),
551
434
 
552
- // #13 — return null on id mismatch instead of silently returning wrong key
553
435
  loadSignedPreKey: (id) => {
554
436
  const key = creds.signedPreKey
555
437
  if (key.keyId !== id) {
@@ -566,18 +448,25 @@ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionRea
566
448
  }
567
449
  },
568
450
 
451
+ // ── Sender keys ───────────────────────────────────────────────────────────
452
+
569
453
  loadSenderKey: async (keyId) => {
570
454
  try {
571
455
  const id = keyId.toString()
572
456
  const { [id]: key } = await keys.get('sender-key', [id])
573
- return toBuffer(key) ?? null
574
- } catch (e) { logger?.error?.(`[Signal] loadSenderKey error: ${e.message}`); return null }
457
+ return toU8(key) ?? null
458
+ } catch (e) {
459
+ logger?.error?.(`[Signal] loadSenderKey error: ${e.message}`)
460
+ return null
461
+ }
575
462
  },
576
463
 
577
464
  storeSenderKey: async (keyId, record) => {
578
465
  await keys.set({ 'sender-key': { [keyId.toString()]: Buffer.from(record) } })
579
466
  },
580
467
 
468
+ // ── Own identity ──────────────────────────────────────────────────────────
469
+
581
470
  getOurRegistrationId: () => creds.registrationId,
582
471
 
583
472
  getOurIdentity: () => ({