@nexustechpro/baileys 2.0.6 → 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.
- package/README.md +71 -0
- package/lib/Defaults/index.js +2 -2
- package/lib/Signal/libsignal.js +552 -358
- package/lib/Socket/chats.js +19 -8
- package/lib/Socket/messages-send.js +33 -15
- package/lib/Socket/newsletter.js +87 -36
- package/lib/Socket/nexus-handler.js +44 -42
- package/lib/Socket/socket.js +23 -10
- package/lib/Store/make-in-memory-store.js +29 -20
- package/lib/Utils/auth-utils.js +2 -2
- package/lib/Utils/decode-wa-message.js +11 -26
- package/lib/Utils/key-store.js +1 -1
- package/lib/Utils/link-preview.js +134 -71
- package/lib/Utils/messages-media.js +97 -26
- package/lib/Utils/messages.js +698 -820
- package/lib/Utils/use-multi-file-auth-state.js +174 -91
- package/lib/index.js +1 -1
- package/package.json +10 -8
package/lib/Signal/libsignal.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|