@nexustechpro/baileys 2.0.5 → 2.1.0

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,366 +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
- const v2Key = (addr) => `${addr}:v2` // v2 slot holds the actual serialized SessionRecord bytes
29
-
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
- }
30
+ const v2Key = (addr) => `${addr}:v2`
56
31
 
57
32
  // ─── Buffer Utils ─────────────────────────────────────────────────────────────
58
33
 
59
- // universal deserializer — handles all shapes written by any previous version
60
34
  const toBuffer = (raw) => {
61
- if (!raw) return null
62
- if (raw instanceof Uint8Array) return raw
63
- if (Buffer.isBuffer(raw)) return raw
64
- if (raw?.type === 'Buffer' && Array.isArray(raw?.data)) return Buffer.from(raw.data)
65
- if (Array.isArray(raw)) return Buffer.from(raw)
66
- if (typeof raw === 'string') return Buffer.from(raw, 'base64')
67
- if (raw?.data) return Buffer.from(raw.data)
68
- 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)
69
51
  }
70
52
 
71
- // detects old libsignal JS JSON format — not deserializable by whatsapp-rust-bridge
72
53
  const isOldJson = (raw) => {
73
- if (!raw || raw instanceof Uint8Array || Buffer.isBuffer(raw)) return false
74
- if (typeof raw === 'object') return 'version' in raw || '_sessions' in raw
75
- if (typeof raw === 'string') { try { const p = JSON.parse(raw); return 'version' in p || '_sessions' in p } catch { return false } }
76
- 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
77
60
  }
78
61
 
79
- // ─── Main Factory ─────────────────────────────────────────────────────────────
62
+ const bufEqual = (a, b) =>
63
+ a && b && a.length === b.length && a.every((byte, i) => byte === b[i])
80
64
 
81
- export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
82
- const lidMapping = new LIDMappingStore(auth.keys, logger, pnToLIDFunc)
83
- const storage = signalStorage(auth, lidMapping, logger)
84
- const parsedKeys = auth.keys
85
- const migratedCache = new LRUCache({ ttl: 7 * 24 * 60 * 60 * 1000, ttlAutopurge: true, updateAgeOnGet: true })
86
- const txn = (fn, key) => parsedKeys.transaction(fn, key)
87
-
88
- return {
89
- decryptGroupMessage({ group, authorJid, msg }) {
90
- return txn(() => new GroupCipher(storage, group, jidToAddr(authorJid)).decrypt(msg), group)
91
- },
92
-
93
- async processSenderKeyDistributionMessage({ item, authorJid }) {
94
- if (!item.groupId) throw new Error('Group ID required')
95
- const senderName = jidToSenderKeyName(item.groupId, authorJid)
96
- const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage)
97
- return txn(() => new GroupSessionBuilder(storage).process(senderName, senderMsg), item.groupId)
98
- },
99
-
100
- async decryptMessage({ jid, type, ciphertext }) {
101
- const addr = jidToAddr(jid)
102
- const cipher = new SessionCipher(storage, addr)
103
- if (type === 'pkmsg') {
104
- const identityKey = extractIdentityFromPkmsg(ciphertext)
105
- if (identityKey) {
106
- const changed = await storage.saveIdentity(addr.toString(), identityKey)
107
- if (changed) logger?.info?.({ jid }, '[Signal] Identity key changed, session cleared')
108
- }
109
- }
110
- const doDecrypt = (c, t) => {
111
- if (t === 'pkmsg') return c.decryptPreKeyWhisperMessage(ciphertext)
112
- if (t === 'msg') return c.decryptWhisperMessage(ciphertext)
113
- throw new Error(`Unknown type: ${t}`)
114
- }
115
- try {
116
- return await txn(() => doDecrypt(cipher, type), jid)
117
- } catch (e) {
118
- if (e?.message?.includes('DuplicatedMessage')) { logger?.debug?.({ jid }, '[Signal] Duplicate message ignored — offline replay'); return null }
119
- throw e
120
- }
121
- },
122
-
123
- encryptMessage({ jid, data }) {
124
- return txn(async () => {
125
- const { type: sigType, body } = await new SessionCipher(storage, jidToAddr(jid)).encrypt(data)
126
- return { type: sigType === 3 ? 'pkmsg' : 'msg', ciphertext: Buffer.from(body) }
127
- }, jid)
128
- },
129
-
130
- encryptGroupMessage({ group, meId, data }) {
131
- return txn(async () => {
132
- const senderName = jidToSenderKeyName(group, meId)
133
- const senderKeyDistributionMessage = await new GroupSessionBuilder(storage).create(senderName)
134
- return { ciphertext: await new GroupCipher(storage, group, jidToAddr(meId)).encrypt(data), senderKeyDistributionMessage: senderKeyDistributionMessage.serialize() }
135
- }, group)
136
- },
137
-
138
- injectE2ESession({ jid, session }) {
139
- return txn(() => new SessionBuilder(storage, jidToAddr(jid)).processPreKeyBundle(session), jid)
140
- },
141
-
142
- jidToSignalProtocolAddress: jid => jidToAddr(jid).toString(),
143
-
144
- lidMapping,
145
-
146
- async validateSession(jid) {
147
- try {
148
- const addr = jidToAddr(jid).toString()
149
- const batch = await migrateIndexKey(parsedKeys, 'session')
150
- const raw = toBuffer(batch[v2Key(addr)]) || toBuffer(batch[addr]) // v2 slot first, fall back to plain
151
- if (!raw || isOldJson(raw)) return { exists: false, reason: 'no session' }
152
- const sess = SessionRecord.deserialize(raw)
153
- if (!sess.haveOpenSession()) return { exists: false, reason: 'no open session' }
154
- return { exists: true }
155
- } catch { return { exists: false, reason: 'error' } }
156
- },
157
-
158
- async deleteSession(jids) {
159
- if (!jids.length) return
160
- return txn(async () => {
161
- const batch = await migrateIndexKey(parsedKeys, 'session')
162
- for (const jid of jids) { const addr = jidToAddr(jid).toString(); delete batch[addr]; delete batch[v2Key(addr)] }
163
- await parsedKeys.set({ session: { 'index': batch } })
164
- }, `del-${jids.length}`)
165
- },
166
-
167
- async migrateSession(fromJid, toJid) {
168
- if (!fromJid || (!isLidUser(toJid) && !isHostedLidUser(toJid))) return { migrated: 0, skipped: 0, total: 0 }
169
- if (!isPnUser(fromJid) && !isHostedPnUser(fromJid)) return { migrated: 0, skipped: 0, total: 1 }
170
- const { user } = jidDecode(fromJid)
171
- const deviceListBatch = await migrateIndexKey(parsedKeys, 'device-list')
172
- const userDevices = deviceListBatch[user]
173
- if (!userDevices?.length) return { migrated: 0, skipped: 0, total: 0 }
174
- const fromDeviceStr = jidDecode(fromJid).device?.toString() || '0'
175
- if (!userDevices.includes(fromDeviceStr)) userDevices.push(fromDeviceStr)
176
- const uncachedDevices = userDevices.filter(d => !migratedCache.has(`${user}.${d}`))
177
- const sessionBatch = await migrateIndexKey(parsedKeys, 'session')
178
- const deviceJids = uncachedDevices.map(d => {
179
- const num = parseInt(d)
180
- return { addr: `${user}.${d || 0}`, jid: num === 99 ? `${user}:99@hosted` : num === 0 ? `${user}@s.whatsapp.net` : `${user}:${num}@s.whatsapp.net` }
181
- }).filter(({ addr }) => sessionBatch[v2Key(addr)] || sessionBatch[addr])
182
- return txn(async () => {
183
- const updated = { ...sessionBatch }
184
- let migrated = 0
185
- for (const { jid } of deviceJids) {
186
- const pnAddr = jidToAddr(jid).toString()
187
- const lidAddr = jidToAddr(transferDevice(jid, toJid)).toString()
188
- const raw = toBuffer(updated[v2Key(pnAddr)]) || toBuffer(updated[pnAddr]) // prefer v2 slot
189
- if (!raw || isOldJson(raw)) continue
190
- const sess = SessionRecord.deserialize(raw)
191
- if (!sess.haveOpenSession()) continue
192
- updated[v2Key(lidAddr)] = sess.serialize()
193
- updated[lidAddr] = { version: 'v1', _sessions: {} } // plain slot marker for compat
194
- delete updated[v2Key(pnAddr)]
195
- delete updated[pnAddr]
196
- migrated++
197
- migratedCache.set(`${user}.${jidDecode(jid).device || 0}`, true)
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
198
87
  }
199
- if (migrated > 0) await parsedKeys.set({ session: { 'index': updated } })
200
- return { migrated, skipped: deviceJids.length - migrated, total: deviceJids.length }
201
- }, `migrate-${deviceJids.length}`)
202
- },
203
-
204
- // Batch-migrate all PN-addressed sessions to their LID equivalents.
205
- // Called once on CB:success before offline messages are processed — one read, one remap, one write.
206
- async migrateAllPNSessionsToLID() {
207
- return txn(async () => {
208
- const sessionBatch = await migrateIndexKey(parsedKeys, 'session')
209
- const sessionKeys = Object.keys(sessionBatch)
210
- if (!sessionKeys.length) return 0
211
-
212
- // collect plain (non-v2) PN-domain keys only — v2 slots are handled via their plain counterpart
213
- const pnAddrs = sessionKeys.filter(addr => {
214
- if (addr.endsWith(':v2')) return false
215
- if (!addr.includes('.')) return false
216
- const [deviceId] = addr.split('.')
217
- const [, dt] = deviceId.split('_')
218
- const domainType = parseInt(dt || '0')
219
- return domainType === WAJIDDomains.WHATSAPP || domainType === WAJIDDomains.HOSTED
220
- })
221
- if (!pnAddrs.length) return 0
88
+ } catch { }
89
+ return undefined
90
+ }
222
91
 
223
- // batch-fetch LID mappings directly from key store — same format storeLIDPNMappings writes
224
- const pnUserSet = new Set(pnAddrs.map(addr => addr.split('.')[0].split('_')[0]))
225
- const stored = await parsedKeys.get('lid-mapping', [...pnUserSet])
92
+ // ─── Main Factory ─────────────────────────────────────────────────────────────
226
93
 
227
- const pnToLidUserMap = new Map()
228
- for (const pnUser of pnUserSet) {
229
- const lidUser = stored[pnUser]
230
- if (lidUser && typeof lidUser === 'string') pnToLidUserMap.set(pnUser, lidUser)
231
- }
232
- if (!pnToLidUserMap.size) return 0
233
-
234
- let migrated = 0
235
- const updated = { ...sessionBatch }
236
-
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 // LID session already exists, skip
246
- const raw = toBuffer(updated[v2Key(addr)]) || toBuffer(updated[addr]) // prefer v2 slot
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: {} } // plain slot marker for compat
252
- delete updated[v2Key(addr)]
253
- delete updated[addr]
254
- migrated++
255
- migratedCache.set(`${user}.${device}`, true)
256
- }
94
+ export function makeLibSignalRepository(auth, logger, pnToLIDFunc) {
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
+ }
257
108
 
258
- if (migrated > 0) {
259
- await parsedKeys.set({ session: { 'index': updated } })
260
- logger?.info?.({ migrated, totalPN: pnAddrs.length, mappingsFound: pnToLidUserMap.size }, '[Signal] Batch-migrated PN sessions to LID on connect')
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?.()
261
399
  }
262
- return migrated
263
- }, 'migrate-all-pn-to-lid')
264
400
  }
265
- }
266
401
  }
267
402
 
268
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
+ }
269
428
 
270
- function signalStorage({ creds, keys }, lidMapping, logger) {
271
- const lidCache = new LRUCache({ max: 500, ttl: 5 * 60 * 1000 }) // cache PN→LID resolutions for 5 min
272
-
273
- const resolveLID = async (id) => {
274
- if (!id.includes('.')) return id
275
- const cached = lidCache.get(id)
276
- if (cached) return cached
277
- const [deviceId, device] = id.split('.')
278
- const [user, dt] = deviceId.split('_')
279
- const domainType = parseInt(dt || '0')
280
- if (domainType === WAJIDDomains.LID || domainType === WAJIDDomains.HOSTED_LID) return id
281
- const pnJid = `${user}${device !== '0' ? `:${device}` : ''}@${domainType === WAJIDDomains.HOSTED ? 'hosted' : 's.whatsapp.net'}`
282
- const lid = await lidMapping.getLIDForPN(pnJid)
283
- const result = lid ? jidToAddr(lid).toString() : id
284
- lidCache.set(id, result)
285
- return result
286
- }
287
-
288
- const getIndex = () => migrateIndexKey(keys, 'session')
289
- const setIndex = (batch) => keys.set({ session: { 'index': batch } })
290
-
291
- return {
292
- loadSession: async (id) => {
293
- try {
294
- const addr = await resolveLID(id)
295
- const batch = await getIndex()
296
- const v2 = batch[v2Key(addr)]
297
- if (v2) {
298
- if (isOldJson(v2)) { logger?.debug?.(`[Signal] Corrupt v2 for ${addr}, will fresh handshake`); return null }
299
- const buf = toBuffer(v2)
300
- if (buf) return buf
301
- }
302
- const plain = batch[addr]
303
- if (!plain || isOldJson(plain)) { // old JS JSON format — not usable by rust bridge
304
- if (plain) logger?.debug?.(`[Signal] Old JSON session for ${addr}, will fresh handshake`)
305
- 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
306
447
  }
307
- return toBuffer(plain)
308
- } catch (e) { logger?.error?.(`[Signal] loadSession error: ${e.message}`); return null }
309
- },
310
-
311
- storeSession: async (id, session) => {
312
- const addr = await resolveLID(id)
313
- const batch = await getIndex()
314
- batch[v2Key(addr)] = session.serialize() // always write to v2 slot
315
- batch[addr] = { version: 'v1', _sessions: {} } // plain slot marker so old code sees something
316
- await setIndex(batch)
317
- },
318
-
319
- isTrustedIdentity: () => true,
320
-
321
- loadIdentityKey: async (id) => {
322
- const addr = await resolveLID(id)
323
- const { [addr]: key } = await keys.get('identity-key', [addr])
324
- const buf = toBuffer(key)
325
- return buf ? new Uint8Array(buf) : undefined
326
- },
327
-
328
- saveIdentity: async (id, identityKey) => {
329
- const addr = await resolveLID(id)
330
- const { [addr]: raw } = await keys.get('identity-key', [addr])
331
- const buf = toBuffer(raw)
332
- const existing = buf ? new Uint8Array(buf) : null
333
- const match = existing && existing.length === identityKey.length && existing.every((b, i) => b === identityKey[i])
334
- if (existing && !match) {
335
- await keys.set({ session: { [addr]: null }, 'identity-key': { [addr]: identityKey } })
336
- return true
337
- }
338
- if (!existing) {
339
- await keys.set({ 'identity-key': { [addr]: identityKey } })
340
- return true
341
- }
342
- return false
343
- },
344
-
345
- loadPreKey: async (id) => {
346
- const { [id.toString()]: key } = await keys.get('pre-key', [id.toString()])
347
- if (!key) return null
348
- return { pubKey: new Uint8Array(Buffer.from(key.public)), privKey: new Uint8Array(Buffer.from(key.private)) }
349
- },
350
-
351
- removePreKey: (id) => keys.set({ 'pre-key': { [id]: null } }),
352
-
353
- loadSignedPreKey: () => {
354
- const { signedPreKey: key } = creds
355
- return { keyId: key.keyId, keyPair: { pubKey: new Uint8Array(Buffer.from(key.keyPair.public)), privKey: new Uint8Array(Buffer.from(key.keyPair.private)) }, signature: new Uint8Array(Buffer.from(key.signature)) }
356
- },
357
-
358
- loadSenderKey: async (keyId) => {
359
- try {
360
- const id = keyId.toString()
361
- const { [id]: key } = await keys.get('sender-key', [id])
362
- return toBuffer(key)
363
- } catch (e) { logger?.error?.(`[Signal] loadSenderKey error: ${e.message}`); return null }
364
- },
365
-
366
- storeSenderKey: async (keyId, record) => {
367
- const id = keyId.toString()
368
- const bytes = record instanceof Uint8Array ? record : Buffer.isBuffer(record) ? record : record.serialize()
369
- await keys.set({ 'sender-key': { [id]: bytes } })
370
- },
371
-
372
- getOurRegistrationId: () => creds.registrationId,
373
-
374
- getOurIdentity: () => {
375
- const { signedIdentityKey } = creds
376
- return { pubKey: new Uint8Array(generateSignalPubKey(Buffer.from(signedIdentityKey.public))), privKey: new Uint8Array(Buffer.from(signedIdentityKey.private)) }
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
+ })
377
587
  }
378
- }
379
588
  }
380
589
 
381
590
  export default makeLibSignalRepository