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:
Gustavo Quadri
2025-09-07 08:12:24 -03:00
committed by GitHub
parent 8f21a67e4a
commit 0043f8be21
10 changed files with 968 additions and 77 deletions
+182 -10
View File
@@ -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) => {
+101
View File
@@ -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
}
}