Add: LID support with lid-map and migrations of sessions (#1694)
* fix: add lid-map logic * fix: lint and simplified version * fix: single device migration * fix: lid map discovery * fix: lint * fix: lid migration in socket connection * fix: lint * fix: decode-wa-message consistency * fix: yarn lock * fix: lint * fix: retry logic * fix: logger level
This commit is contained in:
+182
-10
@@ -1,6 +1,8 @@
|
||||
/* @ts-ignore */
|
||||
import * as libsignal from 'libsignal'
|
||||
import type { SignalAuthState } from '../Types'
|
||||
/* @ts-ignore */
|
||||
import { LRUCache } from 'lru-cache'
|
||||
import type { SignalAuthState, SignalKeyStoreWithTransaction } from '../Types'
|
||||
import type { SignalRepository } from '../Types/Signal'
|
||||
import { generateSignalPubKey } from '../Utils'
|
||||
import { jidDecode } from '../WABinary'
|
||||
@@ -8,10 +10,18 @@ import type { SenderKeyStore } from './Group/group_cipher'
|
||||
import { SenderKeyName } from './Group/sender-key-name'
|
||||
import { SenderKeyRecord } from './Group/sender-key-record'
|
||||
import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage } from './Group'
|
||||
import { LIDMappingStore } from './lid-mapping'
|
||||
|
||||
export function makeLibSignalRepository(auth: SignalAuthState): SignalRepository {
|
||||
const storage: SenderKeyStore = signalStorage(auth)
|
||||
return {
|
||||
const lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction)
|
||||
const storage = signalStorage(auth, lidMapping)
|
||||
// Simple operation-level deduplication (5 minutes)
|
||||
const recentMigrations = new LRUCache<string, boolean>({
|
||||
max: 500,
|
||||
ttl: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
const repository: SignalRepository = {
|
||||
decryptGroupMessage({ group, authorJid, msg }) {
|
||||
const senderName = jidToSignalSenderKeyName(group, authorJid)
|
||||
const cipher = new GroupCipher(storage, senderName)
|
||||
@@ -58,8 +68,36 @@ export function makeLibSignalRepository(auth: SignalAuthState): SignalRepository
|
||||
|
||||
return result
|
||||
},
|
||||
|
||||
async encryptMessage({ jid, data }) {
|
||||
const addr = jidToSignalProtocolAddress(jid)
|
||||
// LID SINGLE SOURCE OF TRUTH: Always prefer LID when available
|
||||
let encryptionJid = jid
|
||||
|
||||
// Check for LID mapping and use it if session exists
|
||||
if (jid.includes('@s.whatsapp.net')) {
|
||||
const lidForPN = await lidMapping.getLIDForPN(jid)
|
||||
if (lidForPN?.includes('@lid')) {
|
||||
const lidAddr = jidToSignalProtocolAddress(lidForPN)
|
||||
const { [lidAddr.toString()]: lidSession } = await auth.keys.get('session', [lidAddr.toString()])
|
||||
|
||||
if (lidSession) {
|
||||
// LID session exists, use it
|
||||
encryptionJid = lidForPN
|
||||
} else {
|
||||
// Try to migrate if PN session exists
|
||||
const pnAddr = jidToSignalProtocolAddress(jid)
|
||||
const { [pnAddr.toString()]: pnSession } = await auth.keys.get('session', [pnAddr.toString()])
|
||||
|
||||
if (pnSession) {
|
||||
// Migrate PN to LID
|
||||
await repository.migrateSession(jid, lidForPN)
|
||||
encryptionJid = lidForPN
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const addr = jidToSignalProtocolAddress(encryptionJid)
|
||||
const cipher = new libsignal.SessionCipher(storage, addr)
|
||||
|
||||
const { type: sigType, body } = await cipher.encrypt(data)
|
||||
@@ -91,26 +129,160 @@ export function makeLibSignalRepository(auth: SignalAuthState): SignalRepository
|
||||
},
|
||||
jidToSignalProtocolAddress(jid) {
|
||||
return jidToSignalProtocolAddress(jid).toString()
|
||||
},
|
||||
|
||||
async storeLIDPNMapping(lid: string, pn: string) {
|
||||
await lidMapping.storeLIDPNMapping(lid, pn)
|
||||
},
|
||||
|
||||
getLIDMappingStore() {
|
||||
return lidMapping
|
||||
},
|
||||
|
||||
async validateSession(jid: string) {
|
||||
try {
|
||||
const addr = jidToSignalProtocolAddress(jid)
|
||||
const session = await storage.loadSession(addr.toString())
|
||||
|
||||
if (!session) {
|
||||
return { exists: false, reason: 'no session' }
|
||||
}
|
||||
|
||||
if (!session.haveOpenSession()) {
|
||||
return { exists: false, reason: 'no open session' }
|
||||
}
|
||||
|
||||
return { exists: true }
|
||||
} catch (error) {
|
||||
return { exists: false, reason: 'validation error' }
|
||||
}
|
||||
},
|
||||
|
||||
async deleteSession(jid: string) {
|
||||
const addr = jidToSignalProtocolAddress(jid)
|
||||
|
||||
return (auth.keys as SignalKeyStoreWithTransaction).transaction(async () => {
|
||||
await auth.keys.set({ session: { [addr.toString()]: null } })
|
||||
})
|
||||
},
|
||||
|
||||
async migrateSession(fromJid: string, toJid: string) {
|
||||
// Only migrate PN → LID
|
||||
if (!fromJid.includes('@s.whatsapp.net') || !toJid.includes('@lid')) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromDecoded = jidDecode(fromJid)
|
||||
const toDecoded = jidDecode(toJid)
|
||||
if (!fromDecoded || !toDecoded) return
|
||||
|
||||
const deviceId = fromDecoded.device || 0
|
||||
const migrationKey = `${fromDecoded.user}.${deviceId}→${toDecoded.user}.${deviceId}`
|
||||
|
||||
// Check if recently migrated (5 min window)
|
||||
if (recentMigrations.has(migrationKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if LID session already exists
|
||||
const lidAddr = jidToSignalProtocolAddress(toJid)
|
||||
const { [lidAddr.toString()]: lidExists } = await auth.keys.get('session', [lidAddr.toString()])
|
||||
if (lidExists) {
|
||||
recentMigrations.set(migrationKey, true)
|
||||
return
|
||||
}
|
||||
|
||||
return (auth.keys as SignalKeyStoreWithTransaction).transaction(async () => {
|
||||
// Store mapping
|
||||
await lidMapping.storeLIDPNMapping(toJid, fromJid)
|
||||
|
||||
// Load and copy session
|
||||
const fromAddr = jidToSignalProtocolAddress(fromJid)
|
||||
const fromSession = await storage.loadSession(fromAddr.toString())
|
||||
|
||||
if (fromSession?.haveOpenSession()) {
|
||||
// Deep copy session to prevent reference issues
|
||||
const sessionBytes = fromSession.serialize()
|
||||
const copiedSession = libsignal.SessionRecord.deserialize(sessionBytes)
|
||||
|
||||
// Store at LID address
|
||||
await storage.storeSession(lidAddr.toString(), copiedSession)
|
||||
|
||||
// Delete PN session - maintain single encryption layer
|
||||
await auth.keys.set({ session: { [fromAddr.toString()]: null } })
|
||||
}
|
||||
|
||||
recentMigrations.set(migrationKey, true)
|
||||
})
|
||||
},
|
||||
|
||||
async encryptMessageWithWire({ encryptionJid, wireJid, data }) {
|
||||
const result = await repository.encryptMessage({ jid: encryptionJid, data })
|
||||
return { ...result, wireJid }
|
||||
},
|
||||
|
||||
destroy() {
|
||||
recentMigrations.clear()
|
||||
}
|
||||
}
|
||||
|
||||
return repository
|
||||
}
|
||||
|
||||
const jidToSignalProtocolAddress = (jid: string) => {
|
||||
const { user, device } = jidDecode(jid)!
|
||||
return new libsignal.ProtocolAddress(user, device || 0)
|
||||
const decoded = jidDecode(jid)!
|
||||
const { user, device, server } = decoded
|
||||
|
||||
// LID addresses get _1 suffix for Signal protocol
|
||||
const signalUser = server === 'lid' ? `${user}_1` : user
|
||||
const finalDevice = device || 0
|
||||
|
||||
return new libsignal.ProtocolAddress(signalUser, finalDevice)
|
||||
}
|
||||
|
||||
const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => {
|
||||
return new SenderKeyName(group, jidToSignalProtocolAddress(user))
|
||||
}
|
||||
|
||||
function signalStorage({ creds, keys }: SignalAuthState): SenderKeyStore & Record<string, any> {
|
||||
function signalStorage(
|
||||
{ creds, keys }: SignalAuthState,
|
||||
lidMapping: LIDMappingStore
|
||||
): SenderKeyStore & Record<string, any> {
|
||||
return {
|
||||
loadSession: async (id: string) => {
|
||||
const { [id]: sess } = await keys.get('session', [id])
|
||||
if (sess) {
|
||||
return libsignal.SessionRecord.deserialize(sess)
|
||||
try {
|
||||
// LID SINGLE SOURCE OF TRUTH: Auto-redirect PN to LID if mapping exists
|
||||
let actualId = id
|
||||
if (id.includes('.') && !id.includes('_1')) {
|
||||
// This is a PN signal address format (e.g., "1234567890.0")
|
||||
// Convert back to JID to check for LID mapping
|
||||
const parts = id.split('.')
|
||||
const device = parts[1] || '0'
|
||||
const pnJid = device === '0' ? `${parts[0]}@s.whatsapp.net` : `${parts[0]}:${device}@s.whatsapp.net`
|
||||
|
||||
const lidForPN = await lidMapping.getLIDForPN(pnJid)
|
||||
if (lidForPN?.includes('@lid')) {
|
||||
const lidAddr = jidToSignalProtocolAddress(lidForPN)
|
||||
const lidId = lidAddr.toString()
|
||||
|
||||
// Check if LID session exists
|
||||
const { [lidId]: lidSession } = await keys.get('session', [lidId])
|
||||
if (lidSession) {
|
||||
actualId = lidId
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { [actualId]: sess } = await keys.get('session', [actualId])
|
||||
|
||||
if (sess) {
|
||||
return libsignal.SessionRecord.deserialize(sess)
|
||||
}
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
// TODO: Replace with libsignal.SessionRecord when type exports are added to libsignal
|
||||
storeSession: async (id: string, session: any) => {
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { SignalKeyStoreWithTransaction } from '../Types'
|
||||
import logger from '../Utils/logger'
|
||||
import { isJidUser, isLidUser, jidDecode } from '../WABinary'
|
||||
|
||||
export class LIDMappingStore {
|
||||
private readonly keys: SignalKeyStoreWithTransaction
|
||||
|
||||
constructor(keys: SignalKeyStoreWithTransaction) {
|
||||
this.keys = keys
|
||||
}
|
||||
|
||||
/**
|
||||
* Store LID-PN mapping - USER LEVEL
|
||||
*/
|
||||
async storeLIDPNMapping(lid: string, pn: string): Promise<void> {
|
||||
// Validate inputs
|
||||
if (!((isLidUser(lid) && isJidUser(pn)) || (isJidUser(lid) && isLidUser(pn)))) {
|
||||
logger.warn(`Invalid LID-PN mapping: ${lid}, ${pn}`)
|
||||
return
|
||||
}
|
||||
|
||||
const [lidJid, pnJid] = isLidUser(lid) ? [lid, pn] : [pn, lid]
|
||||
|
||||
const lidDecoded = jidDecode(lidJid)
|
||||
const pnDecoded = jidDecode(pnJid)
|
||||
|
||||
if (!lidDecoded || !pnDecoded) return
|
||||
|
||||
const pnUser = pnDecoded.user
|
||||
const lidUser = lidDecoded.user
|
||||
|
||||
logger.trace(`Storing USER LID mapping: PN ${pnUser} → LID ${lidUser}`)
|
||||
|
||||
await this.keys.transaction(async () => {
|
||||
await this.keys.set({
|
||||
'lid-mapping': {
|
||||
[pnUser]: lidUser, // "554396160286" -> "102765716062358"
|
||||
[`${lidUser}_reverse`]: pnUser // "102765716062358_reverse" -> "554396160286"
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
logger.trace(`USER LID mapping stored: PN ${pnUser} → LID ${lidUser}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LID for PN - Returns device-specific LID based on user mapping
|
||||
*/
|
||||
async getLIDForPN(pn: string): Promise<string | null> {
|
||||
if (!isJidUser(pn)) return null
|
||||
|
||||
const decoded = jidDecode(pn)
|
||||
if (!decoded) return null
|
||||
|
||||
// Look up user-level mapping (whatsmeow approach)
|
||||
const pnUser = decoded.user
|
||||
const stored = await this.keys.get('lid-mapping', [pnUser])
|
||||
const lidUser = stored[pnUser]
|
||||
|
||||
if (!lidUser) {
|
||||
logger.trace(`No LID mapping found for PN user ${pnUser}`)
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof lidUser !== 'string') return null
|
||||
|
||||
// Push the PN device ID to the LID to maintain device separation
|
||||
const pnDevice = decoded.device !== undefined ? decoded.device : 0
|
||||
const deviceSpecificLid = `${lidUser}:${pnDevice}@lid`
|
||||
|
||||
logger.trace(`getLIDForPN: ${pn} → ${deviceSpecificLid} (user mapping with device ${pnDevice})`)
|
||||
return deviceSpecificLid
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PN for LID - USER LEVEL with device construction
|
||||
*/
|
||||
async getPNForLID(lid: string): Promise<string | null> {
|
||||
if (!isLidUser(lid)) return null
|
||||
|
||||
const decoded = jidDecode(lid)
|
||||
if (!decoded) return null
|
||||
|
||||
// Look up reverse user mapping
|
||||
const lidUser = decoded.user
|
||||
const stored = await this.keys.get('lid-mapping', [`${lidUser}_reverse`])
|
||||
const pnUser = stored[`${lidUser}_reverse`]
|
||||
|
||||
if (!pnUser || typeof pnUser !== 'string') {
|
||||
logger.trace(`No reverse mapping found for LID user: ${lidUser}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Construct device-specific PN JID
|
||||
const lidDevice = decoded.device !== undefined ? decoded.device : 0
|
||||
const pnJid = `${pnUser}:${lidDevice}@s.whatsapp.net`
|
||||
|
||||
logger.trace(`Found reverse mapping: ${lid} → ${pnJid}`)
|
||||
return pnJid
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user