@nexustechpro/baileys 2.0.6 → 2.1.2

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.
@@ -1,12 +1,12 @@
1
1
  import {
2
- SessionCipher,
3
- SessionBuilder,
4
- SessionRecord,
5
- ProtocolAddress,
6
- GroupCipher,
7
- GroupSessionBuilder,
8
- SenderKeyName,
9
- SenderKeyDistributionMessage,
2
+ SessionCipher,
3
+ SessionBuilder,
4
+ SessionRecord,
5
+ ProtocolAddress,
6
+ GroupCipher,
7
+ GroupSessionBuilder,
8
+ SenderKeyName,
9
+ SenderKeyDistributionMessage,
10
10
  } from 'whatsapp-rust-bridge'
11
11
  import { LRUCache } from 'lru-cache'
12
12
  import { generateSignalPubKey, migrateIndexKey } from '../Utils/index.js'
@@ -16,381 +16,575 @@ import { LIDMappingStore } from './lid-mapping.js'
16
16
  // ─── Address Helpers ──────────────────────────────────────────────────────────
17
17
 
18
18
  const jidToAddr = (jid) => {
19
- const { user, device, server, domainType } = jidDecode(jid)
20
- if (!user) throw new Error(`Invalid JID: "${jid}"`)
21
- if (device === 99 && server !== 'hosted' && server !== 'hosted.lid') throw new Error('Invalid device 99: ' + jid)
22
- const signalUser = domainType !== WAJIDDomains.WHATSAPP ? `${user}_${domainType}` : user
23
- return new ProtocolAddress(signalUser, device || 0)
19
+ const { user, device, server, domainType } = jidDecode(jid)
20
+ if (!user) throw new Error(`Invalid JID: "${jid}"`)
21
+ if (device === 99 && server !== 'hosted' && server !== 'hosted.lid') throw new Error('Invalid device 99: ' + jid)
22
+ return new ProtocolAddress(
23
+ domainType !== WAJIDDomains.WHATSAPP ? `${user}_${domainType}` : user,
24
+ device || 0
25
+ )
24
26
  }
25
27
 
26
28
  const jidToSenderKeyName = (group, user) => new SenderKeyName(group, jidToAddr(user))
27
29
 
28
30
  const v2Key = (addr) => `${addr}:v2`
29
31
 
30
- // ─── Identity Extraction ──────────────────────────────────────────────────────
31
-
32
- function extractIdentityFromPkmsg(ciphertext) {
33
- try {
34
- if (!ciphertext || ciphertext.length < 2) return undefined
35
- if ((ciphertext[0] & 0xf) !== 3) return undefined
36
- const buf = ciphertext.slice(1)
37
- let i = 0
38
- while (i < buf.length) {
39
- const tag = buf[i++]
40
- const fieldNum = tag >> 3
41
- const wireType = tag & 0x7
42
- if (wireType === 2) {
43
- let len = 0, shift = 0
44
- while (i < buf.length) { const b = buf[i++]; len |= (b & 0x7f) << shift; if (!(b & 0x80)) break; shift += 7 }
45
- if (fieldNum === 4 && len === 33) return new Uint8Array(buf.slice(i, i + len))
46
- i += len
47
- } else if (wireType === 0) {
48
- while (i < buf.length && buf[i++] & 0x80) { }
49
- } else if (wireType === 5) { i += 4 }
50
- else if (wireType === 1) { i += 8 }
51
- else break
52
- }
53
- } catch { }
54
- return undefined
55
- }
56
-
57
32
  // ─── Buffer Utils ─────────────────────────────────────────────────────────────
58
33
 
59
34
  const toBuffer = (raw) => {
60
- if (!raw) return null
61
- if (raw instanceof Uint8Array) return raw
62
- if (Buffer.isBuffer(raw)) return raw
63
- if (raw?.type === 'Buffer' && Array.isArray(raw?.data)) return Buffer.from(raw.data)
64
- if (Array.isArray(raw)) return Buffer.from(raw)
65
- if (typeof raw === 'string') return Buffer.from(raw, 'base64')
66
- if (raw?.data) return Buffer.from(raw.data)
67
- return null
35
+ if (!raw) return null
36
+ if (raw instanceof Uint8Array) return raw
37
+ if (Buffer.isBuffer(raw)) return raw
38
+ if (raw?.type === 'Buffer' && Array.isArray(raw?.data)) return Buffer.from(raw.data)
39
+ if (Array.isArray(raw)) return Buffer.from(raw)
40
+ if (typeof raw === 'string') return Buffer.from(raw, 'base64')
41
+ if (raw?.data) return Buffer.from(raw.data)
42
+ return null
43
+ }
44
+
45
+ const toU8 = (raw) => {
46
+ const buf = toBuffer(raw)
47
+ if (!buf) return null
48
+ return buf instanceof Uint8Array && buf.constructor === Uint8Array
49
+ ? buf
50
+ : new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength)
68
51
  }
69
52
 
70
53
  const isOldJson = (raw) => {
71
- if (!raw || raw instanceof Uint8Array || Buffer.isBuffer(raw)) return false
72
- if (typeof raw === 'object') return 'version' in raw || '_sessions' in raw
73
- if (typeof raw === 'string') { try { const p = JSON.parse(raw); return 'version' in p || '_sessions' in p } catch { return false } }
74
- return false
54
+ if (!raw || raw instanceof Uint8Array || Buffer.isBuffer(raw)) return false
55
+ if (typeof raw === 'object') return 'version' in raw || '_sessions' in raw
56
+ if (typeof raw === 'string') {
57
+ try { const p = JSON.parse(raw); return 'version' in p || '_sessions' in p } catch { return false }
58
+ }
59
+ return false
60
+ }
61
+
62
+ const bufEqual = (a, b) =>
63
+ a && b && a.length === b.length && a.every((byte, i) => byte === b[i])
64
+
65
+ // ─── Identity Extraction ──────────────────────────────────────────────────────
66
+ // Reads field 4 (identity key, 33 bytes) from PreKeyWhisperMessage protobuf envelope
67
+
68
+ const extractIdentityFromPkmsg = (ciphertext) => {
69
+ try {
70
+ if (!ciphertext || ciphertext.length < 2) return undefined
71
+ 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
75
90
  }
76
91
 
77
92
  // ─── Main Factory ─────────────────────────────────────────────────────────────
78
93
 
79
94
  export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
80
- const lidMapping = new LIDMappingStore(auth.keys, logger, pnToLIDFunc)
81
- const storage = signalStorage(auth, lidMapping, logger)
82
- const parsedKeys = auth.keys
83
- const migratedCache = new LRUCache({ ttl: 7 * 24 * 60 * 60 * 1000, ttlAutopurge: true, updateAgeOnGet: true })
84
- const txn = (fn, key) => parsedKeys.transaction(fn, key)
85
-
86
- return {
87
- decryptGroupMessage({ group, authorJid, msg }) {
88
- return txn(() => new GroupCipher(storage, group, jidToAddr(authorJid)).decrypt(msg), group)
89
- },
90
-
91
- async processSenderKeyDistributionMessage({ item, authorJid }) {
92
- if (!item.groupId) throw new Error('Group ID required')
93
- const senderName = jidToSenderKeyName(item.groupId, authorJid)
94
- const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage)
95
- return txn(() => new GroupSessionBuilder(storage).process(senderName, senderMsg), item.groupId)
96
- },
97
-
98
- async encryptGroupMessage({ group, meId, data }) {
99
- const senderName = jidToSenderKeyName(group, meId)
100
- const skdm = await new GroupSessionBuilder(storage).create(senderName)
101
- const plaintext = data instanceof Uint8Array && data.constructor === Uint8Array
102
- ? data
103
- : new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
104
- const ciphertext = await new GroupCipher(storage, group, jidToAddr(meId)).encrypt(plaintext)
105
- return { ciphertext, senderKeyDistributionMessage: skdm.serialize() }
106
- },
107
-
108
- async getSenderKeyDistributionMessage({ group, meId }) {
109
- const senderName = jidToSenderKeyName(group, meId)
110
- const skdm = await new GroupSessionBuilder(storage).create(senderName)
111
- return skdm.serialize()
112
- },
113
-
114
- async hasSenderKey({ group, meId }) {
115
- const name = jidToSenderKeyName(group, meId).toString()
116
- const { [name]: key } = await parsedKeys.get('sender-key', [name])
117
- return !!toBuffer(key)
118
- },
119
-
120
- async decryptMessage({ jid, type, ciphertext }) {
121
- const addr = jidToAddr(jid)
122
- const cipher = new SessionCipher(storage, addr)
123
- if (type === 'pkmsg') {
124
- const identityKey = extractIdentityFromPkmsg(ciphertext)
125
- if (identityKey) {
126
- const changed = await storage.saveIdentity(addr.toString(), identityKey)
127
- if (changed) logger?.info?.({ jid }, '[Signal] Identity key changed, session cleared')
128
- }
129
- }
130
- try {
131
- return await txn(() => {
132
- if (type === 'pkmsg') return cipher.decryptPreKeyWhisperMessage(ciphertext)
133
- if (type === 'msg') return cipher.decryptWhisperMessage(ciphertext)
134
- throw new Error(`Unknown type: ${type}`)
135
- }, jid)
136
- } catch (e) {
137
- if (e?.message?.includes('DuplicatedMessage')) { logger?.debug?.({ jid }, '[Signal] Duplicate message ignored'); return null }
138
- throw e
139
- }
140
- },
141
-
142
- encryptMessage({ jid, data }) {
143
- return txn(async () => {
144
- const { type: sigType, body } = await new SessionCipher(storage, jidToAddr(jid)).encrypt(data)
145
- return { type: sigType === 3 ? 'pkmsg' : 'msg', ciphertext: Buffer.from(body) }
146
- }, jid)
147
- },
148
-
149
- injectE2ESession({ jid, session }) {
150
- return txn(() => new SessionBuilder(storage, jidToAddr(jid)).processPreKeyBundle(session), jid)
151
- },
152
-
153
- jidToSignalProtocolAddress: jid => jidToAddr(jid).toString(),
154
-
155
- lidMapping,
156
-
157
- async validateSession(jid) {
158
- try {
159
- const addr = jidToAddr(jid).toString()
160
- const batch = await migrateIndexKey(parsedKeys, 'session')
161
- const raw = toBuffer(batch[v2Key(addr)]) || toBuffer(batch[addr])
162
- if (!raw || isOldJson(raw)) return { exists: false, reason: 'no session' }
163
- const sess = SessionRecord.deserialize(raw)
164
- if (!sess.haveOpenSession()) return { exists: false, reason: 'no open session' }
165
- return { exists: true }
166
- } catch { return { exists: false, reason: 'error' } }
167
- },
168
-
169
- async deleteSession(jids) {
170
- if (!jids.length) return
171
- return txn(async () => {
172
- const batch = await migrateIndexKey(parsedKeys, 'session')
173
- for (const jid of jids) { const addr = jidToAddr(jid).toString(); delete batch[addr]; delete batch[v2Key(addr)] }
174
- await parsedKeys.set({ session: { index: batch } })
175
- }, `del-${jids.length}`)
176
- },
177
-
178
- async migrateSession(fromJid, toJid) {
179
- if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 }
180
- if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) return { migrated: 0, skipped: 0, total: 1 }
181
- const { user } = jidDecode(fromJid)
182
- const deviceListBatch = await migrateIndexKey(parsedKeys, 'device-list')
183
- const userDevices = deviceListBatch[user]
184
- if (!userDevices?.length) return { migrated: 0, skipped: 0, total: 0 }
185
- const fromDeviceStr = jidDecode(fromJid).device?.toString() || '0'
186
- if (!userDevices.includes(fromDeviceStr)) userDevices.push(fromDeviceStr)
187
- const uncachedDevices = userDevices.filter(d => !migratedCache.has(`${user}.${d}`))
188
- const sessionBatch = await migrateIndexKey(parsedKeys, 'session')
189
- const deviceJids = uncachedDevices.map(d => {
190
- const num = parseInt(d)
191
- return { addr: `${user}.${d || 0}`, jid: num === 99 ? `${user}:99@hosted` : num === 0 ? `${user}@s.whatsapp.net` : `${user}:${num}@s.whatsapp.net` }
192
- }).filter(({ addr }) => sessionBatch[v2Key(addr)] || sessionBatch[addr])
193
- return txn(async () => {
194
- const updated = { ...sessionBatch }
195
- let migrated = 0
196
- for (const { jid } of deviceJids) {
197
- const pnAddr = jidToAddr(jid).toString()
198
- const lidAddr = jidToAddr(transferDevice(jid, toJid)).toString()
199
- const raw = toBuffer(updated[v2Key(pnAddr)]) || toBuffer(updated[pnAddr])
200
- if (!raw || isOldJson(raw)) continue
201
- const sess = SessionRecord.deserialize(raw)
202
- if (!sess.haveOpenSession()) continue
203
- updated[v2Key(lidAddr)] = sess.serialize()
204
- updated[lidAddr] = { version: 'v1', _sessions: {} }
205
- delete updated[v2Key(pnAddr)]
206
- delete updated[pnAddr]
207
- migrated++
208
- migratedCache.set(`${user}.${jidDecode(jid).device || 0}`, true)
209
- }
210
- if (migrated > 0) await parsedKeys.set({ session: { index: updated } })
211
- return { migrated, skipped: deviceJids.length - migrated, total: deviceJids.length }
212
- }, `migrate-${deviceJids.length}`)
213
- },
214
-
215
- async migrateAllPNSessionsToLID() {
216
- return txn(async () => {
217
- const sessionBatch = await migrateIndexKey(parsedKeys, 'session')
218
- const sessionKeys = Object.keys(sessionBatch)
219
- if (!sessionKeys.length) return 0
220
- const pnAddrs = sessionKeys.filter(addr => {
221
- if (addr.endsWith(':v2') || !addr.includes('.')) return false
222
- const [, dt] = addr.split('.')[0].split('_')
223
- const domainType = parseInt(dt || '0')
224
- return domainType === WAJIDDomains.WHATSAPP || domainType === WAJIDDomains.HOSTED
225
- })
226
- if (!pnAddrs.length) return 0
227
- const pnUserSet = new Set(pnAddrs.map(addr => addr.split('.')[0].split('_')[0]))
228
- const stored = await parsedKeys.get('lid-mapping', [...pnUserSet])
229
- const pnToLidUserMap = new Map()
230
- for (const pnUser of pnUserSet) {
231
- const lidUser = stored[pnUser]
232
- if (lidUser && typeof lidUser === 'string') pnToLidUserMap.set(pnUser, lidUser)
233
- }
234
- if (!pnToLidUserMap.size) return 0
235
- let migrated = 0
236
- const updated = { ...sessionBatch }
237
- for (const addr of pnAddrs) {
238
- const [deviceId, device] = addr.split('.')
239
- const [user, dt] = deviceId.split('_')
240
- const domainType = parseInt(dt || '0')
241
- const lidUser = pnToLidUserMap.get(user)
242
- if (!lidUser) continue
243
- const lidDomainType = domainType === WAJIDDomains.HOSTED ? WAJIDDomains.HOSTED_LID : WAJIDDomains.LID
244
- const lidAddr = `${lidUser}_${lidDomainType}.${device}`
245
- if (updated[v2Key(lidAddr)]) continue
246
- const raw = toBuffer(updated[v2Key(addr)]) || toBuffer(updated[addr])
247
- if (!raw || isOldJson(raw)) continue
248
- const sess = SessionRecord.deserialize(raw)
249
- if (!sess.haveOpenSession()) continue
250
- updated[v2Key(lidAddr)] = sess.serialize()
251
- updated[lidAddr] = { version: 'v1', _sessions: {} }
252
- delete updated[v2Key(addr)]
253
- delete updated[addr]
254
- migrated++
255
- migratedCache.set(`${user}.${device}`, true)
256
- }
257
- if (migrated > 0) {
258
- await parsedKeys.set({ session: { index: updated } })
259
- logger?.info?.({ migrated, totalPN: pnAddrs.length, mappingsFound: pnToLidUserMap.size }, '[Signal] Batch-migrated PN sessions to LID on connect')
95
+ const lidMapping = new LIDMappingStore(auth.keys, logger, pnToLIDFunc)
96
+ const parsedKeys = auth.keys
97
+
98
+ // #17 expose lidCache ref so migrateSession can invalidate it
99
+ const lidCache = new LRUCache({ max: 500, ttl: 5 * 60 * 1000 })
100
+
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
110
+ const sessionReadCache = new LRUCache({ max: 1000, ttl: 5 * 60 * 1000, ttlAutopurge: true })
111
+
112
+ const storage = signalStorage(auth, lidMapping, logger, lidCache, sessionReadCache, withSessionLock)
113
+ const migratedCache = new LRUCache({ ttl: 7 * 24 * 60 * 60 * 1000, ttlAutopurge: true, updateAgeOnGet: true })
114
+ const txn = (fn, key) => parsedKeys.transaction(fn, key)
115
+
116
+ return {
117
+ // ── Group ─────────────────────────────────────────────────────────────────
118
+
119
+ decryptGroupMessage({ group, authorJid, msg }) {
120
+ return txn(() => new GroupCipher(storage, group, jidToAddr(authorJid)).decrypt(msg), group)
121
+ },
122
+
123
+ async processSenderKeyDistributionMessage({ item, authorJid }) {
124
+ if (!item.groupId) throw new Error('Group ID required')
125
+ const senderName = jidToSenderKeyName(item.groupId, authorJid)
126
+ const senderMsg = SenderKeyDistributionMessage.deserialize(
127
+ toU8(item.axolotlSenderKeyDistributionMessage)
128
+ )
129
+ // do NOT pre-store a blank SenderKeyRecord — serialize() returns 0 bytes and bridge throws
130
+ return txn(() => new GroupSessionBuilder(storage).process(senderName, senderMsg), item.groupId)
131
+ },
132
+
133
+ encryptGroupMessage({ group, meId, data }) {
134
+ return txn(async () => {
135
+ const senderName = jidToSenderKeyName(group, meId)
136
+ const skdm = await new GroupSessionBuilder(storage).create(senderName)
137
+ const ciphertext = await new GroupCipher(storage, group, jidToAddr(meId)).encrypt(toU8(data))
138
+ return { ciphertext, senderKeyDistributionMessage: skdm.serialize() }
139
+ }, group)
140
+ },
141
+
142
+ // #10 kept for API completeness but not called by messages-send.js
143
+ getSenderKeyDistributionMessage({ group, meId }) {
144
+ return txn(async () => {
145
+ const senderName = jidToSenderKeyName(group, meId)
146
+ return (await new GroupSessionBuilder(storage).create(senderName)).serialize()
147
+ }, group)
148
+ },
149
+
150
+ async hasSenderKey({ group, meId }) {
151
+ const name = jidToSenderKeyName(group, meId).toString()
152
+ const { [name]: key } = await parsedKeys.get('sender-key', [name])
153
+ return !!toBuffer(key)
154
+ },
155
+
156
+ deleteSenderKey(group, authorJid) {
157
+ return parsedKeys.set({ 'sender-key': { [jidToSenderKeyName(group, authorJid).toString()]: null } })
158
+ },
159
+
160
+ // ── 1:1 ───────────────────────────────────────────────────────────────────
161
+
162
+ async decryptMessage({ jid, type, ciphertext }) {
163
+ const addr = jidToAddr(jid)
164
+ const addrStr = addr.toString()
165
+ const cipher = new SessionCipher(storage, addr)
166
+
167
+ try {
168
+ return await txn(async () => {
169
+ // #8 — identity save inside transaction to prevent concurrent pkmsg race
170
+ if (type === 'pkmsg') {
171
+ const identityKey = extractIdentityFromPkmsg(ciphertext)
172
+ if (identityKey) {
173
+ const changed = await storage.saveIdentity(addrStr, identityKey)
174
+ if (changed) logger?.info?.({ jid }, '[Signal] Identity key changed, session cleared')
175
+ } else {
176
+ logger?.debug?.({ jid }, '[Signal] pkmsg: could not extract identity key from envelope')
177
+ }
178
+ return cipher.decryptPreKeyWhisperMessage(ciphertext)
179
+ }
180
+ if (type === 'msg') return cipher.decryptWhisperMessage(ciphertext)
181
+ throw new Error(`Unknown message type: ${type}`)
182
+ }, jid)
183
+ } catch (e) {
184
+ if (e?.message?.includes('DuplicatedMessage')) {
185
+ logger?.debug?.({ jid }, '[Signal] Duplicate message ignored')
186
+ return null
187
+ }
188
+ // #11 on untrusted identity / session corruption, wipe and let caller retry
189
+ if (e?.message?.includes('UntrustedIdentity') || e?.message?.includes('InvalidMessage')) {
190
+ logger?.warn?.({ jid, err: e.message }, '[Signal] Session error — wiping session for re-handshake')
191
+ await storage.wipeSession(addrStr)
192
+ sessionReadCache.delete(addrStr)
193
+ }
194
+ throw e
195
+ }
196
+ },
197
+
198
+ encryptMessage({ jid, data }) {
199
+ return txn(async () => {
200
+ const { type: sigType, body } = await new SessionCipher(storage, jidToAddr(jid)).encrypt(data)
201
+ return { type: sigType === 3 ? 'pkmsg' : 'msg', ciphertext: Buffer.from(body) }
202
+ }, jid)
203
+ },
204
+
205
+ injectE2ESession({ jid, session }) {
206
+ return txn(() => new SessionBuilder(storage, jidToAddr(jid)).processPreKeyBundle(session), jid)
207
+ },
208
+
209
+ // ── Session management ────────────────────────────────────────────────────
210
+
211
+ jidToSignalProtocolAddress: jid => jidToAddr(jid).toString(),
212
+
213
+ lidMapping,
214
+
215
+ async validateSession(jid) {
216
+ try {
217
+ 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' } }
225
+ },
226
+
227
+ async deleteSession(jids) {
228
+ if (!jids?.length) return
229
+ 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 }
233
+ for (const jid of jids) {
234
+ const addr = jidToAddr(jid).toString()
235
+ delete updated[addr]
236
+ delete updated[v2Key(addr)]
237
+ sessionReadCache.delete(addr)
238
+ }
239
+ await parsedKeys.set({ session: { index: updated } })
240
+ }, `del-${jids.length}`)
241
+ },
242
+
243
+ // ── Session migration ─────────────────────────────────────────────────────
244
+
245
+ async migrateSession(fromJid, toJid) {
246
+ if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 }
247
+ if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) return { migrated: 0, skipped: 0, total: 1 }
248
+ const { user } = jidDecode(fromJid)
249
+
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]] : []
257
+ const fromDeviceStr = jidDecode(fromJid).device?.toString() || '0'
258
+ if (!userDevices.includes(fromDeviceStr)) userDevices.push(fromDeviceStr)
259
+
260
+ const deviceJids = userDevices
261
+ .filter(d => !migratedCache.has(`${user}.${d}`))
262
+ .map(d => {
263
+ 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]
272
+ })
273
+
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
+ }
299
+
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
+ },
304
+
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
335
+ })
336
+ if (!pnAddrs.length) return 0
337
+
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
344
+
345
+ return txn(async () => {
346
+ // #5 — fresh read inside txn before writing
347
+ const freshBatch = await migrateIndexKey(parsedKeys, 'session')
348
+ const updated = { ...freshBatch }
349
+ let migrated = 0
350
+
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])
361
+ if (!raw || isOldJson(raw)) continue
362
+ const sess = SessionRecord.deserialize(raw)
363
+ 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
369
+ lidCache.delete(addr)
370
+ sessionReadCache.delete(addr)
371
+ migrated++
372
+ migratedCache.set(`${user}.${device}`, true)
373
+ }
374
+
375
+ 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')
378
+ }
379
+ return migrated
380
+ }, 'migrate-all-pn-to-lid')
381
+ },
382
+
383
+ // #17 — warm the LID cache on connect from stored mappings
384
+ async warmLIDCache(mappings) {
385
+ for (const { pn, lid } of mappings) {
386
+ try {
387
+ const pnAddr = jidToAddr(pn).toString()
388
+ const lidAddr = jidToAddr(lid).toString()
389
+ lidCache.set(pnAddr, lidAddr)
390
+ } catch { }
391
+ }
392
+ },
393
+
394
+ close() {
395
+ migratedCache.clear()
396
+ sessionReadCache.clear()
397
+ lidCache.clear()
398
+ lidMapping.close?.()
260
399
  }
261
- return migrated
262
- }, 'migrate-all-pn-to-lid')
263
- },
264
-
265
- deleteSenderKey(group, authorJid) {
266
- const senderName = jidToSenderKeyName(group, authorJid).toString()
267
- return parsedKeys.set({ 'sender-key': { [senderName]: null } })
268
- },
269
-
270
- close() {
271
- migratedCache.clear()
272
- lidMapping.close?.()
273
400
  }
274
- }
275
401
  }
276
402
 
277
403
  // ─── Storage Adapter ──────────────────────────────────────────────────────────
404
+ // Implements SignalStorage interface for whatsapp-rust-bridge.
405
+ //
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.
412
+
413
+ function signalStorage({ creds, keys }, lidMapping, logger, lidCache, sessionReadCache, withSessionLock) {
414
+ const resolveLID = async (id) => {
415
+ if (!id.includes('.')) return id
416
+ const cached = lidCache.get(id)
417
+ if (cached) return cached
418
+ const [deviceId, device] = id.split('.')
419
+ const [user, dt] = deviceId.split('_')
420
+ const domainType = parseInt(dt || '0')
421
+ if (domainType === WAJIDDomains.LID || domainType === WAJIDDomains.HOSTED_LID) return id
422
+ const pnJid = `${user}${device !== '0' ? `:${device}` : ''}@${domainType === WAJIDDomains.HOSTED ? 'hosted' : 's.whatsapp.net'}`
423
+ const lid = await lidMapping.getLIDForPN(pnJid)
424
+ const result = lid ? jidToAddr(lid).toString() : id
425
+ lidCache.set(id, result)
426
+ return result
427
+ }
278
428
 
279
- function signalStorage({ creds, keys }, lidMapping, logger) {
280
- const lidCache = new LRUCache({ max: 500, ttl: 5 * 60 * 1000 })
281
-
282
- const resolveLID = async (id) => {
283
- if (!id.includes('.')) return id
284
- const cached = lidCache.get(id)
285
- if (cached) return cached
286
- const [deviceId, device] = id.split('.')
287
- const [user, dt] = deviceId.split('_')
288
- const domainType = parseInt(dt || '0')
289
- if (domainType === WAJIDDomains.LID || domainType === WAJIDDomains.HOSTED_LID) return id
290
- const pnJid = `${user}${device !== '0' ? `:${device}` : ''}@${domainType === WAJIDDomains.HOSTED ? 'hosted' : 's.whatsapp.net'}`
291
- const lid = await lidMapping.getLIDForPN(pnJid)
292
- const result = lid ? jidToAddr(lid).toString() : id
293
- lidCache.set(id, result)
294
- return result
295
- }
296
-
297
- const getIndex = () => migrateIndexKey(keys, 'session')
298
- const setIndex = (batch) => keys.set({ session: { index: batch } })
299
-
300
- return {
301
- loadSession: async (id) => {
302
- try {
303
- const addr = await resolveLID(id)
304
- const batch = await getIndex()
305
- const v2 = batch[v2Key(addr)]
306
- if (v2) {
307
- if (isOldJson(v2)) { logger?.debug?.(`[Signal] Corrupt v2 for ${addr}, will fresh handshake`); return null }
308
- const buf = toBuffer(v2)
309
- if (buf) return buf
310
- }
311
- const plain = batch[addr]
312
- if (!plain || isOldJson(plain)) {
313
- if (plain) logger?.debug?.(`[Signal] Old JSON session for ${addr}, will fresh handshake`)
314
- return null
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
315
447
  }
316
- return toBuffer(plain)
317
- } catch (e) { logger?.error?.(`[Signal] loadSession error: ${e.message}`); return null }
318
- },
319
-
320
- storeSession: async (id, session) => {
321
- const addr = await resolveLID(id)
322
- const batch = await getIndex()
323
- batch[v2Key(addr)] = session.serialize()
324
- batch[addr] = { version: 'v1', _sessions: {} }
325
- await setIndex(batch)
326
- },
327
-
328
- isTrustedIdentity: () => true,
329
-
330
- loadIdentityKey: async (id) => {
331
- const addr = await resolveLID(id)
332
- const { [addr]: key } = await keys.get('identity-key', [addr])
333
- const buf = toBuffer(key)
334
- return buf ? new Uint8Array(buf) : undefined
335
- },
336
-
337
- saveIdentity: async (id, identityKey) => {
338
- const addr = await resolveLID(id)
339
- const { [addr]: raw } = await keys.get('identity-key', [addr])
340
- const buf = toBuffer(raw)
341
- const existing = buf ? new Uint8Array(buf) : null
342
- const match = existing && existing.length === identityKey.length && existing.every((b, i) => b === identityKey[i])
343
- if (existing && !match) {
344
- await keys.set({ session: { [addr]: null }, 'identity-key': { [addr]: identityKey } })
345
- return true
346
- }
347
- if (!existing) {
348
- await keys.set({ 'identity-key': { [addr]: identityKey } })
349
- return true
350
- }
351
- return false
352
- },
353
-
354
- loadPreKey: async (id) => {
355
- const { [id.toString()]: key } = await keys.get('pre-key', [id.toString()])
356
- if (!key) return null
357
- return { pubKey: new Uint8Array(Buffer.from(key.public)), privKey: new Uint8Array(Buffer.from(key.private)) }
358
- },
359
-
360
- removePreKey: (id) => keys.set({ 'pre-key': { [id]: null } }),
361
-
362
- loadSignedPreKey: () => {
363
- const { signedPreKey: key } = creds
364
- return {
365
- keyId: key.keyId,
366
- keyPair: { pubKey: new Uint8Array(Buffer.from(key.keyPair.public)), privKey: new Uint8Array(Buffer.from(key.keyPair.private)) },
367
- signature: new Uint8Array(Buffer.from(key.signature))
368
- }
369
- },
370
-
371
- loadSenderKey: async (keyId) => {
372
- try {
373
- const id = keyId.toString()
374
- const { [id]: key } = await keys.get('sender-key', [id])
375
- return toBuffer(key)
376
- } catch (e) { logger?.error?.(`[Signal] loadSenderKey error: ${e.message}`); return null }
377
- },
378
-
379
- storeSenderKey: async (keyId, record) => {
380
- const id = keyId.toString()
381
- await keys.set({ 'sender-key': { [id]: Buffer.from(record) } })
382
- },
383
-
384
- getOurRegistrationId: () => creds.registrationId,
385
-
386
- getOurIdentity: () => {
387
- const { signedIdentityKey } = creds
388
- return {
389
- pubKey: new Uint8Array(generateSignalPubKey(Buffer.from(signedIdentityKey.public))),
390
- privKey: new Uint8Array(Buffer.from(signedIdentityKey.private))
391
- }
448
+ })
449
+
450
+ return {
451
+ loadSession: async (id) => {
452
+ try {
453
+ const addr = await resolveLID(id)
454
+ // #2 check per-addr cache before reading full index
455
+ const cached = sessionReadCache.get(addr)
456
+ 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`)
467
+ sessionReadCache.set(addr, null)
468
+ return null
469
+ }
470
+ const buf = toU8(plain)
471
+ sessionReadCache.set(addr, buf)
472
+ return buf
473
+ } catch (e) { logger?.error?.(`[Signal] loadSession error: ${e.message}`); return null }
474
+ },
475
+
476
+ storeSession: async (id, record) => {
477
+ const addr = await resolveLID(id)
478
+ 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
+ sessionReadCache.set(addr, toU8(serialized))
488
+ await setIndex(updated)
489
+ },
490
+
491
+ // #7 — proper identity verification instead of always trusting
492
+ isTrustedIdentity: async (id, identityKey) => {
493
+ try {
494
+ const addr = await resolveLID(id)
495
+ const { [addr]: raw } = await keys.get('identity-key', [addr])
496
+ const existing = toU8(raw)
497
+ if (!existing) return true
498
+ return !!bufEqual(existing, identityKey instanceof Uint8Array ? identityKey : toU8(identityKey))
499
+ } catch { return true }
500
+ },
501
+
502
+ loadIdentityKey: async (id) => {
503
+ const addr = await resolveLID(id)
504
+ const { [addr]: key } = await keys.get('identity-key', [addr])
505
+ return toU8(key) ?? undefined
506
+ },
507
+
508
+ saveIdentity: async (id, identityKey) => {
509
+ 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)]
517
+ sessionReadCache.delete(addr)
518
+ sessionReadCache.delete('__index__')
519
+ await setIndex(updated)
520
+ await keys.set({ 'identity-key': { [addr]: identityKey } })
521
+ lidCache.delete(id)
522
+ return true
523
+ }
524
+ if (!existing) {
525
+ await keys.set({ 'identity-key': { [addr]: identityKey } })
526
+ return true
527
+ }
528
+ return false
529
+ },
530
+
531
+ // #11 — exposed for decryptMessage error recovery
532
+ wipeSession: async (addr) => {
533
+ const batch = await getIndex()
534
+ const updated = { ...batch }
535
+ delete updated[addr]
536
+ delete updated[v2Key(addr)]
537
+ sessionReadCache.delete(addr)
538
+ await setIndex(updated)
539
+ },
540
+
541
+ loadPreKey: async (id) => {
542
+ const { [id.toString()]: key } = await keys.get('pre-key', [id.toString()])
543
+ if (!key) return null
544
+ return {
545
+ pubKey: new Uint8Array(Buffer.from(key.public)),
546
+ privKey: new Uint8Array(Buffer.from(key.private))
547
+ }
548
+ },
549
+
550
+ removePreKey: (id) => keys.set({ 'pre-key': { [id]: null } }),
551
+
552
+ // #13 — return null on id mismatch instead of silently returning wrong key
553
+ loadSignedPreKey: (id) => {
554
+ const key = creds.signedPreKey
555
+ if (key.keyId !== id) {
556
+ logger?.warn?.({ requested: id, current: key.keyId }, '[Signal] loadSignedPreKey id mismatch')
557
+ return null
558
+ }
559
+ return {
560
+ keyId: key.keyId,
561
+ keyPair: {
562
+ pubKey: new Uint8Array(Buffer.from(key.keyPair.public)),
563
+ privKey: new Uint8Array(Buffer.from(key.keyPair.private))
564
+ },
565
+ signature: new Uint8Array(Buffer.from(key.signature))
566
+ }
567
+ },
568
+
569
+ loadSenderKey: async (keyId) => {
570
+ try {
571
+ const id = keyId.toString()
572
+ 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 }
575
+ },
576
+
577
+ storeSenderKey: async (keyId, record) => {
578
+ await keys.set({ 'sender-key': { [keyId.toString()]: Buffer.from(record) } })
579
+ },
580
+
581
+ getOurRegistrationId: () => creds.registrationId,
582
+
583
+ getOurIdentity: () => ({
584
+ pubKey: new Uint8Array(generateSignalPubKey(Buffer.from(creds.signedIdentityKey.public))),
585
+ privKey: new Uint8Array(Buffer.from(creds.signedIdentityKey.private))
586
+ })
392
587
  }
393
- }
394
588
  }
395
589
 
396
590
  export default makeLibSignalRepository