feat: implement identity key change detection for Signal protocol
feat: implement identity key change detection for Signal protocol
This commit is contained in:
+452
-13
@@ -1,10 +1,14 @@
|
|||||||
/* @ts-ignore */
|
/* @ts-ignore */
|
||||||
import * as libsignal from 'libsignal'
|
import * as libsignal from 'libsignal'
|
||||||
|
import { createHash } from 'crypto'
|
||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types'
|
import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types'
|
||||||
|
import type { BaileysEventEmitter } from '../Types/Events'
|
||||||
import type { SignalRepositoryWithLIDStore } from '../Types/Signal'
|
import type { SignalRepositoryWithLIDStore } from '../Types/Signal'
|
||||||
import { generateSignalPubKey } from '../Utils'
|
import { generateSignalPubKey } from '../Utils'
|
||||||
import type { ILogger } from '../Utils/logger'
|
import type { ILogger } from '../Utils/logger'
|
||||||
|
import { metrics } from '../Utils/prometheus-metrics.js'
|
||||||
|
import { CircuitBreaker } from '../Utils/circuit-breaker.js'
|
||||||
import {
|
import {
|
||||||
isHostedLidUser,
|
isHostedLidUser,
|
||||||
isHostedPnUser,
|
isHostedPnUser,
|
||||||
@@ -20,13 +24,243 @@ import { SenderKeyRecord } from './Group/sender-key-record'
|
|||||||
import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage } from './Group'
|
import { GroupCipher, GroupSessionBuilder, SenderKeyDistributionMessage } from './Group'
|
||||||
import { LIDMappingStore } from './lid-mapping'
|
import { LIDMappingStore } from './lid-mapping'
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Identity Key Detection Constants
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/** Cache TTL for identity keys - 30 minutes */
|
||||||
|
const IDENTITY_KEY_CACHE_TTL = 30 * 60 * 1000
|
||||||
|
|
||||||
|
/** Maximum number of identity keys to cache */
|
||||||
|
const IDENTITY_KEY_CACHE_MAX = 1000
|
||||||
|
|
||||||
|
/** Curve25519 public key type byte (0x05) */
|
||||||
|
const CURVE25519_KEY_TYPE = 0x05
|
||||||
|
|
||||||
|
/** Expected length of a Curve25519 public key with type byte */
|
||||||
|
const IDENTITY_KEY_LENGTH = 33
|
||||||
|
|
||||||
|
/** PreKeyWhisperMessage version 3 */
|
||||||
|
const PREKEY_MSG_VERSION = 3
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Identity Key Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of identity key save operation
|
||||||
|
*/
|
||||||
|
export interface IdentitySaveResult {
|
||||||
|
/** Whether the identity key changed (true) or is new/unchanged (false for unchanged) */
|
||||||
|
changed: boolean
|
||||||
|
/** Whether this is a new contact (first time seeing their key) */
|
||||||
|
isNew: boolean
|
||||||
|
/** Fingerprint of the previous key (if changed) */
|
||||||
|
previousFingerprint?: string
|
||||||
|
/** Fingerprint of the current/new key */
|
||||||
|
currentFingerprint: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for makeLibSignalRepository
|
||||||
|
*/
|
||||||
|
export interface LibSignalRepositoryOptions {
|
||||||
|
/** Event emitter for broadcasting identity changes */
|
||||||
|
ev?: BaileysEventEmitter
|
||||||
|
/** Circuit breaker for prekey operations (optional) */
|
||||||
|
preKeyCircuitBreaker?: CircuitBreaker
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Identity Key Utility Functions
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a SHA-256 fingerprint of an identity key
|
||||||
|
* This is used to display to users for verification
|
||||||
|
*
|
||||||
|
* @param key - The identity key bytes
|
||||||
|
* @returns Hex string fingerprint
|
||||||
|
*/
|
||||||
|
function generateKeyFingerprint(key: Uint8Array): string {
|
||||||
|
return createHash('sha256').update(key).digest('hex').substring(0, 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract identity key from a PreKeyWhisperMessage
|
||||||
|
*
|
||||||
|
* The PreKeyWhisperMessage format (version 3):
|
||||||
|
* - Byte 0: Version byte (high nibble = current version, low nibble = 3)
|
||||||
|
* - Bytes 1+: Protobuf-encoded PreKeySignalMessage
|
||||||
|
*
|
||||||
|
* The protobuf contains:
|
||||||
|
* - registrationId (uint32)
|
||||||
|
* - preKeyId (uint32, optional)
|
||||||
|
* - signedPreKeyId (uint32)
|
||||||
|
* - baseKey (bytes, 33 bytes - Curve25519 public key)
|
||||||
|
* - identityKey (bytes, 33 bytes - Curve25519 public key)
|
||||||
|
* - message (bytes - the actual encrypted message)
|
||||||
|
*
|
||||||
|
* We manually parse the protobuf to extract identityKey without depending on
|
||||||
|
* the full protobuf library, making this compatible with any Signal implementation.
|
||||||
|
*
|
||||||
|
* @param ciphertext - The raw PreKeyWhisperMessage bytes
|
||||||
|
* @param logger - Logger for debug output
|
||||||
|
* @returns The identity key bytes or undefined if extraction fails
|
||||||
|
*/
|
||||||
|
function extractIdentityFromPkmsg(ciphertext: Uint8Array, logger?: ILogger): Uint8Array | undefined {
|
||||||
|
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'extract' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Minimum size: 1 byte version + at least 34 bytes for minimal protobuf with identity key
|
||||||
|
if (!ciphertext || ciphertext.length < 35) {
|
||||||
|
logger?.debug({ length: ciphertext?.length }, 'Ciphertext too short for identity extraction')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = ciphertext[0]!
|
||||||
|
// Version byte format: high nibble = current version, low nibble = message type (3 = PreKey)
|
||||||
|
const messageType = version & 0x0f
|
||||||
|
if (messageType !== PREKEY_MSG_VERSION) {
|
||||||
|
logger?.debug({ version, messageType }, 'Not a PreKeyWhisperMessage (version 3)')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse protobuf manually - we're looking for field 5 (identityKey)
|
||||||
|
// Protobuf wire format: varint tag (field_number << 3 | wire_type), then value
|
||||||
|
// Wire type 2 = length-delimited (used for bytes)
|
||||||
|
let offset = 1 // Skip version byte
|
||||||
|
|
||||||
|
while (offset < ciphertext.length) {
|
||||||
|
// Read tag varint
|
||||||
|
const tagResult = readVarint(ciphertext, offset)
|
||||||
|
if (!tagResult) break
|
||||||
|
|
||||||
|
const tag = tagResult.value
|
||||||
|
offset = tagResult.nextOffset
|
||||||
|
|
||||||
|
const fieldNumber = tag >> 3
|
||||||
|
const wireType = tag & 0x07
|
||||||
|
|
||||||
|
if (wireType === 2) {
|
||||||
|
// Length-delimited field
|
||||||
|
const lengthResult = readVarint(ciphertext, offset)
|
||||||
|
if (!lengthResult) break
|
||||||
|
|
||||||
|
const length = lengthResult.value
|
||||||
|
offset = lengthResult.nextOffset
|
||||||
|
|
||||||
|
// Field 5 is identityKey
|
||||||
|
if (fieldNumber === 5) {
|
||||||
|
// Validate key length
|
||||||
|
if (length !== IDENTITY_KEY_LENGTH) {
|
||||||
|
logger?.debug({ length, expected: IDENTITY_KEY_LENGTH }, 'Invalid identity key length')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset + length > ciphertext.length) {
|
||||||
|
logger?.debug('Identity key extends beyond ciphertext bounds')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityKey = ciphertext.slice(offset, offset + length)
|
||||||
|
|
||||||
|
// Validate key type byte (must be 0x05 for Curve25519)
|
||||||
|
if (identityKey[0] !== CURVE25519_KEY_TYPE) {
|
||||||
|
logger?.debug({ type: identityKey[0], expected: CURVE25519_KEY_TYPE }, 'Invalid identity key type')
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Uint8Array(identityKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += length
|
||||||
|
} else if (wireType === 0) {
|
||||||
|
// Varint
|
||||||
|
const varintResult = readVarint(ciphertext, offset)
|
||||||
|
if (!varintResult) break
|
||||||
|
offset = varintResult.nextOffset
|
||||||
|
} else if (wireType === 1) {
|
||||||
|
// 64-bit fixed
|
||||||
|
offset += 8
|
||||||
|
} else if (wireType === 5) {
|
||||||
|
// 32-bit fixed
|
||||||
|
offset += 4
|
||||||
|
} else {
|
||||||
|
// Unknown wire type, cannot continue
|
||||||
|
logger?.debug({ wireType }, 'Unknown wire type in protobuf')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.debug('Identity key field not found in PreKeyWhisperMessage')
|
||||||
|
return undefined
|
||||||
|
} catch (error) {
|
||||||
|
logger?.debug({ error: (error as Error).message }, 'Failed to extract identity from pkmsg')
|
||||||
|
return undefined
|
||||||
|
} finally {
|
||||||
|
timer?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a varint from a buffer
|
||||||
|
*
|
||||||
|
* @param buffer - The buffer to read from
|
||||||
|
* @param offset - Starting offset
|
||||||
|
* @returns Object with value and nextOffset, or undefined if invalid
|
||||||
|
*/
|
||||||
|
function readVarint(buffer: Uint8Array, offset: number): { value: number; nextOffset: number } | undefined {
|
||||||
|
let result = 0
|
||||||
|
let shift = 0
|
||||||
|
|
||||||
|
while (offset < buffer.length) {
|
||||||
|
const byte = buffer[offset]!
|
||||||
|
result |= (byte & 0x7f) << shift
|
||||||
|
|
||||||
|
offset++
|
||||||
|
if ((byte & 0x80) === 0) {
|
||||||
|
return { value: result >>> 0, nextOffset: offset } // >>> 0 ensures unsigned
|
||||||
|
}
|
||||||
|
|
||||||
|
shift += 7
|
||||||
|
if (shift > 35) {
|
||||||
|
// Varint too long (max 5 bytes for 32-bit)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
export function makeLibSignalRepository(
|
export function makeLibSignalRepository(
|
||||||
auth: SignalAuthState,
|
auth: SignalAuthState,
|
||||||
logger: ILogger,
|
logger: ILogger,
|
||||||
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>
|
pnToLIDFunc?: (jids: string[]) => Promise<LIDMapping[] | undefined>,
|
||||||
|
options?: LibSignalRepositoryOptions
|
||||||
): SignalRepositoryWithLIDStore {
|
): SignalRepositoryWithLIDStore {
|
||||||
|
const { ev, preKeyCircuitBreaker } = options || {}
|
||||||
const lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction, logger, pnToLIDFunc)
|
const lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction, logger, pnToLIDFunc)
|
||||||
const storage = signalStorage(auth, lidMapping)
|
|
||||||
|
// Identity key cache to avoid repeated storage reads
|
||||||
|
const identityKeyCache = new LRUCache<string, Uint8Array>({
|
||||||
|
max: IDENTITY_KEY_CACHE_MAX,
|
||||||
|
ttl: IDENTITY_KEY_CACHE_TTL,
|
||||||
|
ttlAutopurge: true,
|
||||||
|
updateAgeOnGet: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update cache size metric periodically
|
||||||
|
const cacheMetricsInterval = setInterval(() => {
|
||||||
|
metrics.signalIdentityKeyCacheSize?.set(identityKeyCache.size)
|
||||||
|
}, 60000) // Every minute
|
||||||
|
|
||||||
|
// Cleanup interval on process exit
|
||||||
|
if (typeof process !== 'undefined') {
|
||||||
|
process.on('beforeExit', () => clearInterval(cacheMetricsInterval))
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = signalStorage(auth, lidMapping, identityKeyCache, ev, preKeyCircuitBreaker, logger)
|
||||||
|
|
||||||
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
|
const parsedKeys = auth.keys as SignalKeyStoreWithTransaction
|
||||||
const migratedSessionCache = new LRUCache<string, true>({
|
const migratedSessionCache = new LRUCache<string, true>({
|
||||||
@@ -80,14 +314,51 @@ export function makeLibSignalRepository(
|
|||||||
const session = new libsignal.SessionCipher(storage, addr)
|
const session = new libsignal.SessionCipher(storage, addr)
|
||||||
|
|
||||||
async function doDecrypt() {
|
async function doDecrypt() {
|
||||||
|
// For PreKeyWhisperMessage, extract and verify identity key BEFORE decryption
|
||||||
|
// This handles the case where a contact reinstalled WhatsApp (new identity key)
|
||||||
|
if (type === 'pkmsg') {
|
||||||
|
const identityKey = extractIdentityFromPkmsg(ciphertext, logger)
|
||||||
|
if (identityKey) {
|
||||||
|
const addrStr = addr.toString()
|
||||||
|
try {
|
||||||
|
const saveResult = await storage.saveIdentity(addrStr, identityKey)
|
||||||
|
if (saveResult.changed) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
jid,
|
||||||
|
addr: addrStr,
|
||||||
|
previousFingerprint: saveResult.previousFingerprint,
|
||||||
|
newFingerprint: saveResult.currentFingerprint,
|
||||||
|
},
|
||||||
|
'Identity key changed - contact may have reinstalled WhatsApp, session will be re-established'
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset prekey circuit breaker since we identified the cause
|
||||||
|
if (preKeyCircuitBreaker?.isOpen()) {
|
||||||
|
preKeyCircuitBreaker.reset()
|
||||||
|
logger.debug({ jid }, 'Reset prekey circuit breaker after identity key change detection')
|
||||||
|
}
|
||||||
|
} else if (saveResult.isNew) {
|
||||||
|
logger.debug(
|
||||||
|
{ jid, addr: addrStr, fingerprint: saveResult.currentFingerprint },
|
||||||
|
'New contact identity key saved (Trust On First Use)'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail decryption - identity tracking is best-effort
|
||||||
|
logger.warn(
|
||||||
|
{ jid, error: (error as Error).message },
|
||||||
|
'Failed to save identity key during decryption'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result: Buffer
|
let result: Buffer
|
||||||
switch (type) {
|
if (type === 'pkmsg') {
|
||||||
case 'pkmsg':
|
result = await session.decryptPreKeyWhisperMessage(ciphertext)
|
||||||
result = await session.decryptPreKeyWhisperMessage(ciphertext)
|
} else {
|
||||||
break
|
result = await session.decryptWhisperMessage(ciphertext)
|
||||||
case 'msg':
|
|
||||||
result = await session.decryptWhisperMessage(ciphertext)
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -356,10 +627,37 @@ const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName =>
|
|||||||
return new SenderKeyName(group, jidToSignalProtocolAddress(user))
|
return new SenderKeyName(group, jidToSignalProtocolAddress(user))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended SignalStorage with identity key management
|
||||||
|
* This type adds identity key operations to the standard Signal storage
|
||||||
|
*/
|
||||||
|
type ExtendedSignalStorage = SenderKeyStore & libsignal.SignalStorage & {
|
||||||
|
/**
|
||||||
|
* Load identity key for a contact
|
||||||
|
* @param id - Signal protocol address string
|
||||||
|
* @returns Identity key bytes or undefined if not found
|
||||||
|
*/
|
||||||
|
loadIdentityKey(id: string): Promise<Uint8Array | undefined>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save/update identity key for a contact
|
||||||
|
* Handles Trust On First Use (TOFU) and change detection
|
||||||
|
*
|
||||||
|
* @param id - Signal protocol address string
|
||||||
|
* @param identityKey - The identity key bytes (33 bytes with type prefix)
|
||||||
|
* @returns Result indicating if key changed, is new, and fingerprints
|
||||||
|
*/
|
||||||
|
saveIdentity(id: string, identityKey: Uint8Array): Promise<IdentitySaveResult>
|
||||||
|
}
|
||||||
|
|
||||||
function signalStorage(
|
function signalStorage(
|
||||||
{ creds, keys }: SignalAuthState,
|
{ creds, keys }: SignalAuthState,
|
||||||
lidMapping: LIDMappingStore
|
lidMapping: LIDMappingStore,
|
||||||
): SenderKeyStore & libsignal.SignalStorage {
|
identityKeyCache: LRUCache<string, Uint8Array>,
|
||||||
|
ev?: BaileysEventEmitter,
|
||||||
|
preKeyCircuitBreaker?: CircuitBreaker,
|
||||||
|
logger?: ILogger
|
||||||
|
): ExtendedSignalStorage {
|
||||||
// Shared function to resolve PN signal address to LID if mapping exists
|
// Shared function to resolve PN signal address to LID if mapping exists
|
||||||
const resolveLIDSignalAddress = async (id: string): Promise<string> => {
|
const resolveLIDSignalAddress = async (id: string): Promise<string> => {
|
||||||
if (id.includes('.')) {
|
if (id.includes('.')) {
|
||||||
@@ -401,7 +699,7 @@ function signalStorage(
|
|||||||
await keys.set({ session: { [wireJid]: session.serialize() } })
|
await keys.set({ session: { [wireJid]: session.serialize() } })
|
||||||
},
|
},
|
||||||
isTrustedIdentity: () => {
|
isTrustedIdentity: () => {
|
||||||
return true // todo: implement
|
return true // todo: implement proper trust management
|
||||||
},
|
},
|
||||||
loadPreKey: async (id: number | string) => {
|
loadPreKey: async (id: number | string) => {
|
||||||
const keyId = id.toString()
|
const keyId = id.toString()
|
||||||
@@ -442,6 +740,147 @@ function signalStorage(
|
|||||||
privKey: Buffer.from(signedIdentityKey.private),
|
privKey: Buffer.from(signedIdentityKey.private),
|
||||||
pubKey: Buffer.from(generateSignalPubKey(signedIdentityKey.public))
|
pubKey: Buffer.from(generateSignalPubKey(signedIdentityKey.public))
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Identity Key Management (NEW)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load identity key for a contact from cache or storage
|
||||||
|
*/
|
||||||
|
loadIdentityKey: async (id: string): Promise<Uint8Array | undefined> => {
|
||||||
|
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'load' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wireJid = await resolveLIDSignalAddress(id)
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = identityKeyCache.get(wireJid)
|
||||||
|
if (cached) {
|
||||||
|
metrics.signalIdentityKeyCacheHits?.inc()
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.signalIdentityKeyCacheMisses?.inc()
|
||||||
|
|
||||||
|
// Load from storage
|
||||||
|
const { [wireJid]: key } = await keys.get('identity-key', [wireJid])
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
// Populate cache
|
||||||
|
identityKeyCache.set(wireJid, key)
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
} finally {
|
||||||
|
timer?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save identity key for a contact with change detection
|
||||||
|
* Implements Trust On First Use (TOFU) and emits events on changes
|
||||||
|
*/
|
||||||
|
saveIdentity: async (id: string, identityKey: Uint8Array): Promise<IdentitySaveResult> => {
|
||||||
|
const timer = metrics.signalIdentityKeyOperations?.startTimer({ operation: 'save' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wireJid = await resolveLIDSignalAddress(id)
|
||||||
|
const currentFingerprint = generateKeyFingerprint(identityKey)
|
||||||
|
|
||||||
|
// Load existing key (from cache or storage)
|
||||||
|
const { [wireJid]: existingKey } = await keys.get('identity-key', [wireJid])
|
||||||
|
|
||||||
|
// Check if keys match
|
||||||
|
const keysMatch =
|
||||||
|
existingKey &&
|
||||||
|
existingKey.length === identityKey.length &&
|
||||||
|
existingKey.every((byte, i) => byte === identityKey[i])
|
||||||
|
|
||||||
|
if (existingKey && !keysMatch) {
|
||||||
|
// IDENTITY KEY CHANGED - contact reinstalled WhatsApp or switched devices
|
||||||
|
const previousFingerprint = generateKeyFingerprint(existingKey)
|
||||||
|
|
||||||
|
// Delete old session and save new identity key atomically
|
||||||
|
await keys.set({
|
||||||
|
session: { [wireJid]: null },
|
||||||
|
'identity-key': { [wireJid]: identityKey },
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
identityKeyCache.set(wireJid, identityKey)
|
||||||
|
|
||||||
|
// Record metrics
|
||||||
|
metrics.signalIdentityChanges?.inc({ type: 'changed' })
|
||||||
|
|
||||||
|
// Emit event for application to notify user
|
||||||
|
if (ev) {
|
||||||
|
ev.emit('identity.changed', {
|
||||||
|
jid: wireJid,
|
||||||
|
previousKeyFingerprint: previousFingerprint,
|
||||||
|
newKeyFingerprint: currentFingerprint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isNewContact: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger?.warn(
|
||||||
|
{
|
||||||
|
event: 'identity_key_changed',
|
||||||
|
jid: wireJid,
|
||||||
|
previousFingerprint,
|
||||||
|
newFingerprint: currentFingerprint,
|
||||||
|
},
|
||||||
|
'Contact identity key changed - security code changed'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: true,
|
||||||
|
isNew: false,
|
||||||
|
previousFingerprint,
|
||||||
|
currentFingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingKey) {
|
||||||
|
// NEW CONTACT - Trust On First Use (TOFU)
|
||||||
|
await keys.set({ 'identity-key': { [wireJid]: identityKey } })
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
identityKeyCache.set(wireJid, identityKey)
|
||||||
|
|
||||||
|
// Record metrics
|
||||||
|
metrics.signalIdentityChanges?.inc({ type: 'new' })
|
||||||
|
|
||||||
|
// Emit event for new contact
|
||||||
|
if (ev) {
|
||||||
|
ev.emit('identity.changed', {
|
||||||
|
jid: wireJid,
|
||||||
|
previousKeyFingerprint: null,
|
||||||
|
newKeyFingerprint: currentFingerprint,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
isNewContact: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
isNew: true,
|
||||||
|
currentFingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key unchanged
|
||||||
|
return {
|
||||||
|
changed: false,
|
||||||
|
isNew: false,
|
||||||
|
currentFingerprint,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
timer?.()
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ export type SignalDataTypeMap = {
|
|||||||
'lid-mapping': string
|
'lid-mapping': string
|
||||||
'device-list': string[]
|
'device-list': string[]
|
||||||
tctoken: { token: Buffer; timestamp?: string }
|
tctoken: { token: Buffer; timestamp?: string }
|
||||||
|
/** Identity key for Signal Protocol - used for detecting contact reinstalls */
|
||||||
|
'identity-key': Uint8Array
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SignalDataSet = { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } }
|
export type SignalDataSet = { [T in keyof SignalDataTypeMap]?: { [id: string]: SignalDataTypeMap[T] | null } }
|
||||||
|
|||||||
@@ -134,6 +134,25 @@ export type BaileysEventMap = {
|
|||||||
setting: 'channelsPersonalisedRecommendation'
|
setting: 'channelsPersonalisedRecommendation'
|
||||||
value: proto.SyncActionValue.IPrivacySettingChannelsPersonalisedRecommendationAction
|
value: proto.SyncActionValue.IPrivacySettingChannelsPersonalisedRecommendationAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emitted when a contact's Signal identity key changes.
|
||||||
|
* This typically indicates the contact reinstalled WhatsApp or switched devices.
|
||||||
|
* Applications can use this to notify users about the security code change,
|
||||||
|
* similar to the "Security code changed" notification in the official WhatsApp client.
|
||||||
|
*/
|
||||||
|
'identity.changed': {
|
||||||
|
/** JID of the contact whose identity key changed */
|
||||||
|
jid: string
|
||||||
|
/** SHA-256 fingerprint of the previous identity key (hex string) */
|
||||||
|
previousKeyFingerprint: string | null
|
||||||
|
/** SHA-256 fingerprint of the new identity key (hex string) */
|
||||||
|
newKeyFingerprint: string
|
||||||
|
/** Timestamp when the change was detected */
|
||||||
|
timestamp: number
|
||||||
|
/** Whether this is a new contact (true) or an existing contact with changed key (false) */
|
||||||
|
isNewContact: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BufferedEventData = {
|
export type BufferedEventData = {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LRUCache } from 'lru-cache'
|
import { LRUCache } from 'lru-cache'
|
||||||
import type { proto } from '../../WAProto/index.js'
|
import type { proto } from '../../WAProto/index.js'
|
||||||
import type { ILogger } from './logger'
|
import type { ILogger } from './logger'
|
||||||
|
import { metrics } from './prometheus-metrics.js'
|
||||||
|
|
||||||
/** Number of sent messages to cache in memory for handling retry receipts */
|
/** Number of sent messages to cache in memory for handling retry receipts */
|
||||||
const RECENT_MESSAGES_SIZE = 512
|
const RECENT_MESSAGES_SIZE = 512
|
||||||
@@ -10,6 +11,63 @@ const MESSAGE_KEY_SEPARATOR = '\u0000'
|
|||||||
/** Timeout for session recreation - 1 hour */
|
/** Timeout for session recreation - 1 hour */
|
||||||
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000 // 1 hour in milliseconds
|
const RECREATE_SESSION_TIMEOUT = 60 * 60 * 1000 // 1 hour in milliseconds
|
||||||
const PHONE_REQUEST_DELAY = 3000
|
const PHONE_REQUEST_DELAY = 3000
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry reason codes from WhatsApp protocol
|
||||||
|
* These map to the error codes sent in retry receipts
|
||||||
|
*
|
||||||
|
* @see https://github.com/WhiskeySockets/Baileys/pull/2307
|
||||||
|
*/
|
||||||
|
export enum RetryReason {
|
||||||
|
/** Unknown or unspecified error */
|
||||||
|
UnknownError = 0,
|
||||||
|
/** No Signal session exists for recipient */
|
||||||
|
SignalErrorNoSession = 1,
|
||||||
|
/** Invalid key format or corrupted key */
|
||||||
|
SignalErrorInvalidKey = 2,
|
||||||
|
/** Invalid pre-key ID (key not found) */
|
||||||
|
SignalErrorInvalidKeyId = 3,
|
||||||
|
/** Invalid message - MAC verification failed */
|
||||||
|
SignalErrorInvalidMessage = 4,
|
||||||
|
/** Invalid signature on message or key */
|
||||||
|
SignalErrorInvalidSignature = 5,
|
||||||
|
/** Message from the future (timestamp issue) */
|
||||||
|
SignalErrorFutureMessage = 6,
|
||||||
|
/** Explicit MAC verification failure */
|
||||||
|
SignalErrorBadMac = 7,
|
||||||
|
/** Session is corrupted or invalid state */
|
||||||
|
SignalErrorInvalidSession = 8,
|
||||||
|
/** Invalid message key (decryption key issue) */
|
||||||
|
SignalErrorInvalidMsgKey = 9,
|
||||||
|
/** Bad broadcast ephemeral setting */
|
||||||
|
BadBroadcastEphemeralSetting = 10,
|
||||||
|
/** Unknown companion device without pre-key */
|
||||||
|
UnknownCompanionNoPrekey = 11,
|
||||||
|
/** ADV (Announcement Delivery Verification) failure */
|
||||||
|
AdvFailure = 12,
|
||||||
|
/** Status revoke was delayed */
|
||||||
|
StatusRevokeDelay = 13,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MAC error codes that indicate identity key mismatch
|
||||||
|
* These errors occur when the sender's identity key has changed (e.g., reinstalled WhatsApp)
|
||||||
|
* and require immediate session recreation without waiting for the normal timeout
|
||||||
|
*/
|
||||||
|
export const MAC_ERROR_CODES = new Set<RetryReason>([
|
||||||
|
RetryReason.SignalErrorInvalidMessage,
|
||||||
|
RetryReason.SignalErrorBadMac,
|
||||||
|
])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session-related error codes that may require session recreation
|
||||||
|
*/
|
||||||
|
export const SESSION_ERROR_CODES = new Set<RetryReason>([
|
||||||
|
RetryReason.SignalErrorNoSession,
|
||||||
|
RetryReason.SignalErrorInvalidSession,
|
||||||
|
RetryReason.SignalErrorInvalidKey,
|
||||||
|
RetryReason.SignalErrorInvalidKeyId,
|
||||||
|
])
|
||||||
export interface RecentMessageKey {
|
export interface RecentMessageKey {
|
||||||
to: string
|
to: string
|
||||||
id: string
|
id: string
|
||||||
@@ -107,19 +165,65 @@ export class MessageRetryManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a session should be recreated based on retry count and history
|
* Check if a session should be recreated based on retry count, history, and error code
|
||||||
|
*
|
||||||
|
* @param jid - The JID of the recipient
|
||||||
|
* @param hasSession - Whether a Signal session exists for this JID
|
||||||
|
* @param errorCode - Optional error code from the retry receipt (indicates type of failure)
|
||||||
|
* @returns Object with reason string and boolean indicating if session should be recreated
|
||||||
*/
|
*/
|
||||||
shouldRecreateSession(jid: string, hasSession: boolean): { reason: string; recreate: boolean } {
|
shouldRecreateSession(
|
||||||
|
jid: string,
|
||||||
|
hasSession: boolean,
|
||||||
|
errorCode?: RetryReason
|
||||||
|
): { reason: string; recreate: boolean } {
|
||||||
// If we don't have a session, always recreate
|
// If we don't have a session, always recreate
|
||||||
if (!hasSession) {
|
if (!hasSession) {
|
||||||
this.sessionRecreateHistory.set(jid, Date.now())
|
this.sessionRecreateHistory.set(jid, Date.now())
|
||||||
this.statistics.sessionRecreations++
|
this.statistics.sessionRecreations++
|
||||||
|
metrics.signalSessionRecreations?.inc({ reason: 'no_session' })
|
||||||
return {
|
return {
|
||||||
reason: "we don't have a Signal session with them",
|
reason: "we don't have a Signal session with them",
|
||||||
recreate: true
|
recreate: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MAC errors require IMMEDIATE session recreation regardless of history
|
||||||
|
// This handles the case where contact reinstalled WhatsApp (identity key changed)
|
||||||
|
if (errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)) {
|
||||||
|
this.sessionRecreateHistory.set(jid, Date.now())
|
||||||
|
this.statistics.sessionRecreations++
|
||||||
|
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
|
||||||
|
metrics.signalMacErrors?.inc({ action: 'session_recreation' })
|
||||||
|
metrics.signalSessionRecreations?.inc({ reason: 'mac_error' })
|
||||||
|
this.logger.warn(
|
||||||
|
{ jid, errorCode: reasonName },
|
||||||
|
'MAC error detected, forcing immediate session recreation'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
reason: `MAC error (${reasonName}) - contact may have reinstalled WhatsApp`,
|
||||||
|
recreate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session-related errors also warrant recreation
|
||||||
|
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
|
||||||
|
const now = Date.now()
|
||||||
|
const prevTime = this.sessionRecreateHistory.get(jid)
|
||||||
|
// For session errors, use a shorter timeout (5 minutes) since these are more severe
|
||||||
|
const sessionErrorTimeout = 5 * 60 * 1000
|
||||||
|
if (!prevTime || now - prevTime > sessionErrorTimeout) {
|
||||||
|
this.sessionRecreateHistory.set(jid, now)
|
||||||
|
this.statistics.sessionRecreations++
|
||||||
|
const reasonName = RetryReason[errorCode] || `code_${errorCode}`
|
||||||
|
metrics.signalSessionRecreations?.inc({ reason: 'session_error' })
|
||||||
|
return {
|
||||||
|
reason: `Session error (${reasonName})`,
|
||||||
|
recreate: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const prevTime = this.sessionRecreateHistory.get(jid)
|
const prevTime = this.sessionRecreateHistory.get(jid)
|
||||||
|
|
||||||
@@ -127,6 +231,7 @@ export class MessageRetryManager {
|
|||||||
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
|
if (!prevTime || now - prevTime > RECREATE_SESSION_TIMEOUT) {
|
||||||
this.sessionRecreateHistory.set(jid, now)
|
this.sessionRecreateHistory.set(jid, now)
|
||||||
this.statistics.sessionRecreations++
|
this.statistics.sessionRecreations++
|
||||||
|
metrics.signalSessionRecreations?.inc({ reason: 'timeout_exceeded' })
|
||||||
return {
|
return {
|
||||||
reason: 'retry count > 1 and over an hour since last recreation',
|
reason: 'retry count > 1 and over an hour since last recreation',
|
||||||
recreate: true
|
recreate: true
|
||||||
@@ -136,6 +241,51 @@ export class MessageRetryManager {
|
|||||||
return { reason: '', recreate: false }
|
return { reason: '', recreate: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse error code from retry receipt attribute
|
||||||
|
*
|
||||||
|
* @param errorAttr - The error attribute string from the retry receipt
|
||||||
|
* @returns Parsed RetryReason or undefined if invalid
|
||||||
|
*/
|
||||||
|
parseRetryErrorCode(errorAttr: string | undefined): RetryReason | undefined {
|
||||||
|
if (errorAttr === undefined || errorAttr === '') {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = parseInt(errorAttr, 10)
|
||||||
|
if (Number.isNaN(code)) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate code is within known range
|
||||||
|
if (code >= RetryReason.UnknownError && code <= RetryReason.StatusRevokeDelay) {
|
||||||
|
return code as RetryReason
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown code, treat as UnknownError
|
||||||
|
return RetryReason.UnknownError
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error code indicates a MAC verification failure
|
||||||
|
*
|
||||||
|
* @param errorCode - The retry error code to check
|
||||||
|
* @returns True if this is a MAC error requiring immediate session recreation
|
||||||
|
*/
|
||||||
|
isMacError(errorCode: RetryReason | undefined): boolean {
|
||||||
|
return errorCode !== undefined && MAC_ERROR_CODES.has(errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error code indicates a session-related failure
|
||||||
|
*
|
||||||
|
* @param errorCode - The retry error code to check
|
||||||
|
* @returns True if this is a session error
|
||||||
|
*/
|
||||||
|
isSessionError(errorCode: RetryReason | undefined): boolean {
|
||||||
|
return errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Increment retry counter for a message
|
* Increment retry counter for a message
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1601,6 +1601,35 @@ export const metrics = {
|
|||||||
new Gauge('prekey_count', 'Current prekey count')
|
new Gauge('prekey_count', 'Current prekey count')
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ========== Signal Identity Metrics ==========
|
||||||
|
/** Total identity key changes detected (contact reinstalled WhatsApp) */
|
||||||
|
signalIdentityChanges: baileysMetrics.register(
|
||||||
|
new Counter('signal_identity_changes_total', 'Total identity key changes detected', ['type'])
|
||||||
|
),
|
||||||
|
/** Total Signal MAC errors encountered */
|
||||||
|
signalMacErrors: baileysMetrics.register(
|
||||||
|
new Counter('signal_mac_errors_total', 'Total Signal MAC verification errors', ['action'])
|
||||||
|
),
|
||||||
|
/** Total Signal session recreations by reason */
|
||||||
|
signalSessionRecreations: baileysMetrics.register(
|
||||||
|
new Counter('signal_session_recreations_total', 'Total Signal session recreations', ['reason'])
|
||||||
|
),
|
||||||
|
/** Identity key cache statistics */
|
||||||
|
signalIdentityKeyCacheHits: baileysMetrics.register(
|
||||||
|
new Counter('signal_identity_key_cache_hits_total', 'Total identity key cache hits')
|
||||||
|
),
|
||||||
|
signalIdentityKeyCacheMisses: baileysMetrics.register(
|
||||||
|
new Counter('signal_identity_key_cache_misses_total', 'Total identity key cache misses')
|
||||||
|
),
|
||||||
|
/** Identity key operations latency */
|
||||||
|
signalIdentityKeyOperations: baileysMetrics.register(
|
||||||
|
new Histogram('signal_identity_key_operations_ms', 'Identity key operation latency in ms', ['operation'], [1, 5, 10, 25, 50, 100])
|
||||||
|
),
|
||||||
|
/** Current identity key cache size */
|
||||||
|
signalIdentityKeyCacheSize: baileysMetrics.register(
|
||||||
|
new Gauge('signal_identity_key_cache_size', 'Current identity key cache size')
|
||||||
|
),
|
||||||
|
|
||||||
// ========== Cache Metrics ==========
|
// ========== Cache Metrics ==========
|
||||||
cacheHits: baileysMetrics.register(
|
cacheHits: baileysMetrics.register(
|
||||||
new Counter('cache_hits_total', 'Total cache hits', ['cache'])
|
new Counter('cache_hits_total', 'Total cache hits', ['cache'])
|
||||||
|
|||||||
Reference in New Issue
Block a user