f83a1c2aff
* mutex reimplementation * lint * lint * lint fix * Add cleanup for expired sender key mutexes Introduces a mechanism to periodically clean up unused sender key mutexes in addTransactionCapability, reducing memory usage by removing mutexes that have not been used for over an hour and are not locked. A timer is started when the first mutex is created, and cleanup runs every 30 minutes. * Update auth-utils.ts * Refactor Signal key transaction usage Introduces a local variable for parsed Signal keys in libsignal.ts to avoid repeated type assertions and improve code clarity. * Refactor transaction handling for key operations Introduces per-entity mutexes and passes key identifiers to transaction calls for finer-grained concurrency control in Signal key storage and socket operations. Updates transaction signatures and usage across Signal, Socket, and Utils modules to improve atomicity and performance, especially for system and sync messages. * Improve SignalKeyStore transaction handling Refactored transaction logic in SignalKeyStore to add commit retries, cleanup of transaction state, and improved mutex management for sender keys. Enhanced validation and batching for pre-key deletions and updates, improving concurrency and reliability. * Replace custom mutex cleanup with LRU cache Switched from manual mutex cleanup logic to using the lru-cache package for managing mutexes in addTransactionCapability. This simplifies resource management and ensures expired mutexes are automatically purged. Added lru-cache as a dependency. * Lint fix * Update src/Signal/libsignal.ts * Update libsignal.ts --------- Co-authored-by: João Lucas <jlucaso@hotmail.com> Co-authored-by: Rajeh Taher <rajeh@reforward.dev>
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
/* @ts-ignore */
|
|
import * as libsignal from 'libsignal'
|
|
/* @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'
|
|
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 lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction)
|
|
const storage = signalStorage(auth, lidMapping)
|
|
|
|
|
|
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
|
|
|
|
function isLikelySyncMessage(addr: any): boolean {
|
|
const key = addr.toString()
|
|
|
|
// Only bypass for WhatsApp system addresses, not regular user contacts
|
|
// Be very specific about sync service patterns
|
|
return (
|
|
key.includes('@lid.whatsapp.net') || // WhatsApp system messages
|
|
key.includes('@broadcast') || // Broadcast messages
|
|
key.includes('@newsletter')
|
|
)
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Use transaction to ensure atomicity
|
|
return parsedKeys.transaction(async () => {
|
|
return cipher.decrypt(msg)
|
|
}, group)
|
|
},
|
|
async processSenderKeyDistributionMessage({ item, authorJid }) {
|
|
const builder = new GroupSessionBuilder(storage)
|
|
if (!item.groupId) {
|
|
throw new Error('Group ID is required for sender key distribution message')
|
|
}
|
|
|
|
const senderName = jidToSignalSenderKeyName(item.groupId, authorJid)
|
|
|
|
const senderMsg = new SenderKeyDistributionMessage(
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
item.axolotlSenderKeyDistributionMessage
|
|
)
|
|
const senderNameStr = senderName.toString()
|
|
const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr])
|
|
if (!senderKey) {
|
|
await storage.storeSenderKey(senderName, new SenderKeyRecord())
|
|
}
|
|
|
|
return parsedKeys.transaction(async () => {
|
|
const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr])
|
|
if (!senderKey) {
|
|
await storage.storeSenderKey(senderName, new SenderKeyRecord())
|
|
}
|
|
|
|
await builder.process(senderName, senderMsg)
|
|
}, item.groupId)
|
|
},
|
|
async decryptMessage({ jid, type, ciphertext }) {
|
|
const addr = jidToSignalProtocolAddress(jid)
|
|
const session = new libsignal.SessionCipher(storage, addr)
|
|
|
|
async function doDecrypt() {
|
|
let result: Buffer
|
|
switch (type) {
|
|
case 'pkmsg':
|
|
result = await session.decryptPreKeyWhisperMessage(ciphertext)
|
|
break
|
|
case 'msg':
|
|
result = await session.decryptWhisperMessage(ciphertext)
|
|
break
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
if (isLikelySyncMessage(addr)) {
|
|
// If it's a sync message, we can skip the transaction
|
|
// as it is likely to be a system message that doesn't require strict atomicity
|
|
return await doDecrypt()
|
|
}
|
|
|
|
// If it's not a sync message, we need to ensure atomicity
|
|
// For regular messages, we use a transaction to ensure atomicity
|
|
return parsedKeys.transaction(async () => {
|
|
return await doDecrypt()
|
|
}, jid)
|
|
},
|
|
|
|
async encryptMessage({ jid, data }) {
|
|
// 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)
|
|
|
|
// Use transaction to ensure atomicity
|
|
return parsedKeys.transaction(async () => {
|
|
const { type: sigType, body } = await cipher.encrypt(data)
|
|
const type = sigType === 3 ? 'pkmsg' : 'msg'
|
|
return { type, ciphertext: Buffer.from(body, 'binary') }
|
|
}, jid)
|
|
},
|
|
async encryptGroupMessage({ group, meId, data }) {
|
|
const senderName = jidToSignalSenderKeyName(group, meId)
|
|
const builder = new GroupSessionBuilder(storage)
|
|
|
|
const senderNameStr = senderName.toString()
|
|
|
|
return parsedKeys.transaction(async () => {
|
|
const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr])
|
|
if (!senderKey) {
|
|
await storage.storeSenderKey(senderName, new SenderKeyRecord())
|
|
}
|
|
|
|
const senderKeyDistributionMessage = await builder.create(senderName)
|
|
const session = new GroupCipher(storage, senderName)
|
|
const ciphertext = await session.encrypt(data)
|
|
|
|
return {
|
|
ciphertext,
|
|
senderKeyDistributionMessage: senderKeyDistributionMessage.serialize()
|
|
}
|
|
}, group)
|
|
},
|
|
async injectE2ESession({ jid, session }) {
|
|
const cipher = new libsignal.SessionBuilder(storage, jidToSignalProtocolAddress(jid))
|
|
return parsedKeys.transaction(async () => {
|
|
await cipher.initOutgoing(session)
|
|
}, jid)
|
|
},
|
|
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' }
|
|
}
|
|
},
|
|
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 parsedKeys.transaction(async () => {
|
|
await auth.keys.set({ session: { [addr.toString()]: null } })
|
|
}, jid)
|
|
},
|
|
|
|
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 parsedKeys.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)
|
|
}, fromJid)
|
|
},
|
|
|
|
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 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,
|
|
lidMapping: LIDMappingStore
|
|
): SenderKeyStore & Record<string, any> {
|
|
return {
|
|
loadSession: async (id: string) => {
|
|
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) => {
|
|
await keys.set({ session: { [id]: session.serialize() } })
|
|
},
|
|
isTrustedIdentity: () => {
|
|
return true
|
|
},
|
|
loadPreKey: async (id: number | string) => {
|
|
const keyId = id.toString()
|
|
const { [keyId]: key } = await keys.get('pre-key', [keyId])
|
|
if (key) {
|
|
return {
|
|
privKey: Buffer.from(key.private),
|
|
pubKey: Buffer.from(key.public)
|
|
}
|
|
}
|
|
},
|
|
removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }),
|
|
loadSignedPreKey: () => {
|
|
const key = creds.signedPreKey
|
|
return {
|
|
privKey: Buffer.from(key.keyPair.private),
|
|
pubKey: Buffer.from(key.keyPair.public)
|
|
}
|
|
},
|
|
loadSenderKey: async (senderKeyName: SenderKeyName) => {
|
|
const keyId = senderKeyName.toString()
|
|
const { [keyId]: key } = await keys.get('sender-key', [keyId])
|
|
if (key) {
|
|
return SenderKeyRecord.deserialize(key)
|
|
}
|
|
|
|
return new SenderKeyRecord()
|
|
},
|
|
storeSenderKey: async (senderKeyName: SenderKeyName, key: SenderKeyRecord) => {
|
|
const keyId = senderKeyName.toString()
|
|
const serialized = JSON.stringify(key.serialize())
|
|
await keys.set({ 'sender-key': { [keyId]: Buffer.from(serialized, 'utf-8') } })
|
|
},
|
|
getOurRegistrationId: () => creds.registrationId,
|
|
getOurIdentity: () => {
|
|
const { signedIdentityKey } = creds
|
|
return {
|
|
privKey: Buffer.from(signedIdentityKey.private),
|
|
pubKey: generateSignalPubKey(signedIdentityKey.public)
|
|
}
|
|
}
|
|
}
|
|
}
|