From 986a737c277bd942b563ff25253e111d9f5b6e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 15 Nov 2025 21:12:16 -0300 Subject: [PATCH 01/71] feat: extend signalStorage type to include loadSignedPreKey method --- package.json | 1 + src/Signal/libsignal.ts | 35 ++++++++++++++++------------------- yarn.lock | 8 ++++++++ 3 files changed, 25 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index cda0ecaa796..2f7cac66c9a 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", + "whatsapp-rust-bridge": "^0.4.0-alpha.0", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 228dc06281b..ea7b36f9689 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -1,7 +1,7 @@ -/* @ts-ignore */ import * as libsignal from 'libsignal' import { LRUCache } from 'lru-cache' -import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' +import { ProtocolAddress, SessionBuilder, SessionCipher, SessionRecord } from 'whatsapp-rust-bridge/binary' +import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction, SignedKeyPair } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' import { generateSignalPubKey } from '../Utils' import type { ILogger } from '../Utils/logger' @@ -77,10 +77,10 @@ export function makeLibSignalRepository( }, async decryptMessage({ jid, type, ciphertext }) { const addr = jidToSignalProtocolAddress(jid) - const session = new libsignal.SessionCipher(storage, addr) + const session = new SessionCipher(storage, addr) async function doDecrypt() { - let result: Buffer + let result: Uint8Array switch (type) { case 'pkmsg': result = await session.decryptPreKeyWhisperMessage(ciphertext) @@ -102,13 +102,13 @@ export function makeLibSignalRepository( async encryptMessage({ jid, data }) { const addr = jidToSignalProtocolAddress(jid) - const cipher = new libsignal.SessionCipher(storage, addr) + const cipher = new 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') } + return { type, ciphertext: Buffer.from(body) } }, jid) }, @@ -137,7 +137,7 @@ export function makeLibSignalRepository( async injectE2ESession({ jid, session }) { logger.trace({ jid }, 'injecting E2EE session') - const cipher = new libsignal.SessionBuilder(storage, jidToSignalProtocolAddress(jid)) + const cipher = new SessionBuilder(storage, jidToSignalProtocolAddress(jid)) return parsedKeys.transaction(async () => { await cipher.initOutgoing(session) }, jid) @@ -259,8 +259,8 @@ export function makeLibSignalRepository( pnUser: string lidUser: string deviceId: number - fromAddr: libsignal.ProtocolAddress - toAddr: libsignal.ProtocolAddress + fromAddr: ProtocolAddress + toAddr: ProtocolAddress } const migrationOps: MigrationOp[] = deviceJids.map(jid => { @@ -296,7 +296,7 @@ export function makeLibSignalRepository( const pnSession = pnSessions[pnAddrStr] if (pnSession) { // Session exists (guaranteed from device discovery) - const fromSession = libsignal.SessionRecord.deserialize(pnSession) + const fromSession = SessionRecord.deserialize(pnSession) if (fromSession.haveOpenSession()) { // Queue for bulk update: copy to LID, delete from PN sessionUpdates[lidAddrStr] = fromSession.serialize() @@ -332,7 +332,7 @@ export function makeLibSignalRepository( return repository } -const jidToSignalProtocolAddress = (jid: string): libsignal.ProtocolAddress => { +const jidToSignalProtocolAddress = (jid: string): ProtocolAddress => { const decoded = jidDecode(jid)! const { user, device, server, domainType } = decoded @@ -349,7 +349,7 @@ const jidToSignalProtocolAddress = (jid: string): libsignal.ProtocolAddress => { throw new Error('Unexpected non-hosted device JID with device 99. This ID seems invalid. ID:' + jid) } - return new libsignal.ProtocolAddress(signalUser, finalDevice) + return new ProtocolAddress(signalUser, finalDevice) } const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => { @@ -359,7 +359,7 @@ const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => function signalStorage( { creds, keys }: SignalAuthState, lidMapping: LIDMappingStore -): SenderKeyStore & libsignal.SignalStorage { +): Omit & SenderKeyStore & { loadSignedPreKey: () => SignedKeyPair } { // Shared function to resolve PN signal address to LID if mapping exists const resolveLIDSignalAddress = async (id: string): Promise => { if (id.includes('.')) { @@ -388,7 +388,7 @@ function signalStorage( const { [wireJid]: sess } = await keys.get('session', [wireJid]) if (sess) { - return libsignal.SessionRecord.deserialize(sess) + return SessionRecord.deserialize(sess) } } catch (e) { return null @@ -396,7 +396,7 @@ function signalStorage( return null }, - storeSession: async (id: string, session: libsignal.SessionRecord) => { + storeSession: async (id: string, session: SessionRecord) => { const wireJid = await resolveLIDSignalAddress(id) await keys.set({ session: { [wireJid]: session.serialize() } }) }, @@ -416,10 +416,7 @@ function signalStorage( 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) - } + return key }, loadSenderKey: async (senderKeyName: SenderKeyName) => { const keyId = senderKeyName.toString() diff --git a/yarn.lock b/yarn.lock index 13b75cc13b5..10a967f4935 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3025,6 +3025,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" + whatsapp-rust-bridge: "npm:^0.4.0-alpha.0" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10184,6 +10185,13 @@ __metadata: languageName: node linkType: hard +"whatsapp-rust-bridge@npm:^0.4.0-alpha.0": + version: 0.4.0-alpha.0 + resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.0" + checksum: 10c0/ab70e92fd1cd1be9e293ca8e951af2d4078c061611ed83befb86164236adbeb02760c563dc1e501c5b8ce12fd55334849fc691aa567934d27622336034c01688 + languageName: node + linkType: hard + "whatwg-url@npm:^5.0.0": version: 5.0.0 resolution: "whatwg-url@npm:5.0.0" From 5a719a242301e22058d7cd9f2dd17085e880da84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 15 Nov 2025 23:32:36 -0300 Subject: [PATCH 02/71] feat: group support --- package.json | 2 +- src/Signal/Group/ciphertext-message.ts | 9 -- src/Signal/Group/group-session-builder.ts | 56 -------- src/Signal/Group/group_cipher.ts | 117 --------------- src/Signal/Group/index.ts | 11 -- src/Signal/Group/keyhelper.ts | 28 ---- src/Signal/Group/sender-chain-key.ts | 34 ----- .../Group/sender-key-distribution-message.ts | 95 ------------- src/Signal/Group/sender-key-message.ts | 96 ------------- src/Signal/Group/sender-key-name.ts | 66 --------- src/Signal/Group/sender-key-record.ts | 69 --------- src/Signal/Group/sender-key-state.ts | 134 ------------------ src/Signal/Group/sender-message-key.ts | 36 ----- src/Signal/libsignal.ts | 88 ++++-------- .../Group/sender-key-state-regression.test.ts | 22 --- yarn.lock | 10 +- 16 files changed, 35 insertions(+), 838 deletions(-) delete mode 100644 src/Signal/Group/ciphertext-message.ts delete mode 100644 src/Signal/Group/group-session-builder.ts delete mode 100644 src/Signal/Group/group_cipher.ts delete mode 100644 src/Signal/Group/index.ts delete mode 100644 src/Signal/Group/keyhelper.ts delete mode 100644 src/Signal/Group/sender-chain-key.ts delete mode 100644 src/Signal/Group/sender-key-distribution-message.ts delete mode 100644 src/Signal/Group/sender-key-message.ts delete mode 100644 src/Signal/Group/sender-key-name.ts delete mode 100644 src/Signal/Group/sender-key-record.ts delete mode 100644 src/Signal/Group/sender-key-state.ts delete mode 100644 src/Signal/Group/sender-message-key.ts delete mode 100644 src/__tests__/Signal/Group/sender-key-state-regression.test.ts diff --git a/package.json b/package.json index 2f7cac66c9a..cf74d21ca1c 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0-alpha.0", + "whatsapp-rust-bridge": "^0.4.0-alpha.1", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/Group/ciphertext-message.ts b/src/Signal/Group/ciphertext-message.ts deleted file mode 100644 index 238e0151767..00000000000 --- a/src/Signal/Group/ciphertext-message.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class CiphertextMessage { - readonly UNSUPPORTED_VERSION: number = 1 - readonly CURRENT_VERSION: number = 3 - readonly WHISPER_TYPE: number = 2 - readonly PREKEY_TYPE: number = 3 - readonly SENDERKEY_TYPE: number = 4 - readonly SENDERKEY_DISTRIBUTION_TYPE: number = 5 - readonly ENCRYPTED_MESSAGE_OVERHEAD: number = 53 -} diff --git a/src/Signal/Group/group-session-builder.ts b/src/Signal/Group/group-session-builder.ts deleted file mode 100644 index b2a90b61e82..00000000000 --- a/src/Signal/Group/group-session-builder.ts +++ /dev/null @@ -1,56 +0,0 @@ -import * as keyhelper from './keyhelper' -import { SenderKeyDistributionMessage } from './sender-key-distribution-message' -import { SenderKeyName } from './sender-key-name' -import { SenderKeyRecord } from './sender-key-record' - -interface SenderKeyStore { - loadSenderKey(senderKeyName: SenderKeyName): Promise - storeSenderKey(senderKeyName: SenderKeyName, record: SenderKeyRecord): Promise -} - -export class GroupSessionBuilder { - private readonly senderKeyStore: SenderKeyStore - - constructor(senderKeyStore: SenderKeyStore) { - this.senderKeyStore = senderKeyStore - } - - public async process( - senderKeyName: SenderKeyName, - senderKeyDistributionMessage: SenderKeyDistributionMessage - ): Promise { - const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName) - senderKeyRecord.addSenderKeyState( - senderKeyDistributionMessage.getId(), - senderKeyDistributionMessage.getIteration(), - senderKeyDistributionMessage.getChainKey(), - senderKeyDistributionMessage.getSignatureKey() - ) - await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord) - } - - public async create(senderKeyName: SenderKeyName): Promise { - const senderKeyRecord = await this.senderKeyStore.loadSenderKey(senderKeyName) - - if (senderKeyRecord.isEmpty()) { - const keyId = keyhelper.generateSenderKeyId() - const senderKey = keyhelper.generateSenderKey() - const signingKey = keyhelper.generateSenderSigningKey() - - senderKeyRecord.setSenderKeyState(keyId, 0, senderKey, signingKey) - await this.senderKeyStore.storeSenderKey(senderKeyName, senderKeyRecord) - } - - const state = senderKeyRecord.getSenderKeyState() - if (!state) { - throw new Error('No session state available') - } - - return new SenderKeyDistributionMessage( - state.getKeyId(), - state.getSenderChainKey().getIteration(), - state.getSenderChainKey().getSeed(), - state.getSigningKeyPublic() - ) - } -} diff --git a/src/Signal/Group/group_cipher.ts b/src/Signal/Group/group_cipher.ts deleted file mode 100644 index 0f6c7f67ddc..00000000000 --- a/src/Signal/Group/group_cipher.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { decrypt, encrypt } from 'libsignal/src/crypto' -import { SenderKeyMessage } from './sender-key-message' -import { SenderKeyName } from './sender-key-name' -import { SenderKeyRecord } from './sender-key-record' -import { SenderKeyState } from './sender-key-state' - -export interface SenderKeyStore { - loadSenderKey(senderKeyName: SenderKeyName): Promise - - storeSenderKey(senderKeyName: SenderKeyName, record: SenderKeyRecord): Promise -} - -export class GroupCipher { - private readonly senderKeyStore: SenderKeyStore - private readonly senderKeyName: SenderKeyName - - constructor(senderKeyStore: SenderKeyStore, senderKeyName: SenderKeyName) { - this.senderKeyStore = senderKeyStore - this.senderKeyName = senderKeyName - } - - public async encrypt(paddedPlaintext: Uint8Array): Promise { - const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName) - if (!record) { - throw new Error('No SenderKeyRecord found for encryption') - } - - const senderKeyState = record.getSenderKeyState() - if (!senderKeyState) { - throw new Error('No session to encrypt message') - } - - const iteration = senderKeyState.getSenderChainKey().getIteration() - const senderKey = this.getSenderKey(senderKeyState, iteration === 0 ? 0 : iteration + 1) - - const ciphertext = await this.getCipherText(senderKey.getIv(), senderKey.getCipherKey(), paddedPlaintext) - - const senderKeyMessage = new SenderKeyMessage( - senderKeyState.getKeyId(), - senderKey.getIteration(), - ciphertext, - senderKeyState.getSigningKeyPrivate() - ) - - await this.senderKeyStore.storeSenderKey(this.senderKeyName, record) - return senderKeyMessage.serialize() - } - - public async decrypt(senderKeyMessageBytes: Uint8Array): Promise { - const record = await this.senderKeyStore.loadSenderKey(this.senderKeyName) - if (!record) { - throw new Error('No SenderKeyRecord found for decryption') - } - - const senderKeyMessage = new SenderKeyMessage(null, null, null, null, senderKeyMessageBytes) - const senderKeyState = record.getSenderKeyState(senderKeyMessage.getKeyId()) - if (!senderKeyState) { - throw new Error('No session found to decrypt message') - } - - senderKeyMessage.verifySignature(senderKeyState.getSigningKeyPublic()) - const senderKey = this.getSenderKey(senderKeyState, senderKeyMessage.getIteration()) - - const plaintext = await this.getPlainText( - senderKey.getIv(), - senderKey.getCipherKey(), - senderKeyMessage.getCipherText() - ) - - await this.senderKeyStore.storeSenderKey(this.senderKeyName, record) - return plaintext - } - - private getSenderKey(senderKeyState: SenderKeyState, iteration: number) { - let senderChainKey = senderKeyState.getSenderChainKey() - if (senderChainKey.getIteration() > iteration) { - if (senderKeyState.hasSenderMessageKey(iteration)) { - const messageKey = senderKeyState.removeSenderMessageKey(iteration) - if (!messageKey) { - throw new Error('No sender message key found for iteration') - } - - return messageKey - } - - throw new Error(`Received message with old counter: ${senderChainKey.getIteration()}, ${iteration}`) - } - - if (iteration - senderChainKey.getIteration() > 2000) { - throw new Error('Over 2000 messages into the future!') - } - - while (senderChainKey.getIteration() < iteration) { - senderKeyState.addSenderMessageKey(senderChainKey.getSenderMessageKey()) - senderChainKey = senderChainKey.getNext() - } - - senderKeyState.setSenderChainKey(senderChainKey.getNext()) - return senderChainKey.getSenderMessageKey() - } - - private async getPlainText(iv: Uint8Array, key: Uint8Array, ciphertext: Uint8Array): Promise { - try { - return decrypt(key, ciphertext, iv) - } catch (e) { - throw new Error('InvalidMessageException') - } - } - - private async getCipherText(iv: Uint8Array, key: Uint8Array, plaintext: Uint8Array): Promise { - try { - return encrypt(key, plaintext, iv) - } catch (e) { - throw new Error('InvalidMessageException') - } - } -} diff --git a/src/Signal/Group/index.ts b/src/Signal/Group/index.ts deleted file mode 100644 index 52c983d7b10..00000000000 --- a/src/Signal/Group/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { GroupSessionBuilder } from './group-session-builder' -export { SenderKeyDistributionMessage } from './sender-key-distribution-message' -export { SenderKeyRecord } from './sender-key-record' -export { SenderKeyName } from './sender-key-name' -export { GroupCipher } from './group_cipher' -export { SenderKeyState } from './sender-key-state' -export { SenderKeyMessage } from './sender-key-message' -export { SenderMessageKey } from './sender-message-key' -export { SenderChainKey } from './sender-chain-key' -export { CiphertextMessage } from './ciphertext-message' -export * as keyhelper from './keyhelper' diff --git a/src/Signal/Group/keyhelper.ts b/src/Signal/Group/keyhelper.ts deleted file mode 100644 index acf274c660c..00000000000 --- a/src/Signal/Group/keyhelper.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as nodeCrypto from 'crypto' -import { generateKeyPair } from 'libsignal/src/curve' - -type KeyPairType = ReturnType - -export function generateSenderKey(): Buffer { - return nodeCrypto.randomBytes(32) -} - -export function generateSenderKeyId(): number { - return nodeCrypto.randomInt(2147483647) -} - -export interface SigningKeyPair { - public: Buffer - private: Buffer -} - -export function generateSenderSigningKey(key?: KeyPairType): SigningKeyPair { - if (!key) { - key = generateKeyPair() - } - - return { - public: Buffer.from(key.pubKey), - private: Buffer.from(key.privKey) - } -} diff --git a/src/Signal/Group/sender-chain-key.ts b/src/Signal/Group/sender-chain-key.ts deleted file mode 100644 index 18d5cbf883b..00000000000 --- a/src/Signal/Group/sender-chain-key.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { calculateMAC } from 'libsignal/src/crypto' -import { SenderMessageKey } from './sender-message-key' - -export class SenderChainKey { - private readonly MESSAGE_KEY_SEED: Uint8Array = Buffer.from([0x01]) - private readonly CHAIN_KEY_SEED: Uint8Array = Buffer.from([0x02]) - private readonly iteration: number - private readonly chainKey: Buffer - - constructor(iteration: number, chainKey: Uint8Array | Buffer) { - this.iteration = iteration - this.chainKey = Buffer.from(chainKey) - } - - public getIteration(): number { - return this.iteration - } - - public getSenderMessageKey(): SenderMessageKey { - return new SenderMessageKey(this.iteration, this.getDerivative(this.MESSAGE_KEY_SEED, this.chainKey)) - } - - public getNext(): SenderChainKey { - return new SenderChainKey(this.iteration + 1, this.getDerivative(this.CHAIN_KEY_SEED, this.chainKey)) - } - - public getSeed(): Uint8Array { - return this.chainKey - } - - private getDerivative(seed: Uint8Array, key: Buffer): Uint8Array { - return calculateMAC(key, seed) - } -} diff --git a/src/Signal/Group/sender-key-distribution-message.ts b/src/Signal/Group/sender-key-distribution-message.ts deleted file mode 100644 index 9888ae3895d..00000000000 --- a/src/Signal/Group/sender-key-distribution-message.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { proto } from '../../../WAProto/index.js' -import { CiphertextMessage } from './ciphertext-message' - -interface SenderKeyDistributionMessageStructure { - id: number - iteration: number - chainKey: string | Uint8Array - signingKey: string | Uint8Array -} - -export class SenderKeyDistributionMessage extends CiphertextMessage { - private readonly id: number - private readonly iteration: number - private readonly chainKey: Uint8Array - private readonly signatureKey: Uint8Array - private readonly serialized: Uint8Array - - constructor( - id?: number | null, - iteration?: number | null, - chainKey?: Uint8Array | null, - signatureKey?: Uint8Array | null, - serialized?: Uint8Array | null - ) { - super() - - if (serialized) { - try { - const message = serialized.slice(1) - const distributionMessage = proto.SenderKeyDistributionMessage.decode( - message - ).toJSON() as SenderKeyDistributionMessageStructure - - this.serialized = serialized - this.id = distributionMessage.id - this.iteration = distributionMessage.iteration - this.chainKey = - typeof distributionMessage.chainKey === 'string' - ? Buffer.from(distributionMessage.chainKey, 'base64') - : distributionMessage.chainKey - this.signatureKey = - typeof distributionMessage.signingKey === 'string' - ? Buffer.from(distributionMessage.signingKey, 'base64') - : distributionMessage.signingKey - } catch (e) { - throw new Error(String(e)) - } - } else { - const version = this.intsToByteHighAndLow(this.CURRENT_VERSION, this.CURRENT_VERSION) - this.id = id! - this.iteration = iteration! - this.chainKey = chainKey! - this.signatureKey = signatureKey! - - const message = proto.SenderKeyDistributionMessage.encode( - proto.SenderKeyDistributionMessage.create({ - id, - iteration, - chainKey, - signingKey: this.signatureKey - }) - ).finish() - - this.serialized = Buffer.concat([Buffer.from([version]), message]) - } - } - - private intsToByteHighAndLow(highValue: number, lowValue: number): number { - return (((highValue << 4) | lowValue) & 0xff) % 256 - } - - public serialize(): Uint8Array { - return this.serialized - } - - public getType(): number { - return this.SENDERKEY_DISTRIBUTION_TYPE - } - - public getIteration(): number { - return this.iteration - } - - public getChainKey(): Uint8Array { - return this.chainKey - } - - public getSignatureKey(): Uint8Array { - return this.signatureKey - } - - public getId(): number { - return this.id - } -} diff --git a/src/Signal/Group/sender-key-message.ts b/src/Signal/Group/sender-key-message.ts deleted file mode 100644 index e6d8ac14058..00000000000 --- a/src/Signal/Group/sender-key-message.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { calculateSignature, verifySignature } from 'libsignal/src/curve' -import { proto } from '../../../WAProto/index.js' -import { CiphertextMessage } from './ciphertext-message' - -interface SenderKeyMessageStructure { - id: number - iteration: number - ciphertext: string | Buffer -} - -export class SenderKeyMessage extends CiphertextMessage { - private readonly SIGNATURE_LENGTH = 64 - private readonly messageVersion: number - private readonly keyId: number - private readonly iteration: number - private readonly ciphertext: Uint8Array - private readonly signature: Uint8Array - private readonly serialized: Uint8Array - - constructor( - keyId?: number | null, - iteration?: number | null, - ciphertext?: Uint8Array | null, - signatureKey?: Uint8Array | null, - serialized?: Uint8Array | null - ) { - super() - - if (serialized) { - const version = serialized[0]! - const message = serialized.slice(1, serialized.length - this.SIGNATURE_LENGTH) - const signature = serialized.slice(-1 * this.SIGNATURE_LENGTH) - const senderKeyMessage = proto.SenderKeyMessage.decode(message).toJSON() as SenderKeyMessageStructure - - this.serialized = serialized - this.messageVersion = (version & 0xff) >> 4 - this.keyId = senderKeyMessage.id - this.iteration = senderKeyMessage.iteration - this.ciphertext = - typeof senderKeyMessage.ciphertext === 'string' - ? Buffer.from(senderKeyMessage.ciphertext, 'base64') - : senderKeyMessage.ciphertext - this.signature = signature - } else { - const version = (((this.CURRENT_VERSION << 4) | this.CURRENT_VERSION) & 0xff) % 256 - const ciphertextBuffer = Buffer.from(ciphertext!) - const message = proto.SenderKeyMessage.encode( - proto.SenderKeyMessage.create({ - id: keyId!, - iteration: iteration!, - ciphertext: ciphertextBuffer - }) - ).finish() - - const signature = this.getSignature(signatureKey!, Buffer.concat([Buffer.from([version]), message])) - - this.serialized = Buffer.concat([Buffer.from([version]), message, Buffer.from(signature)]) - this.messageVersion = this.CURRENT_VERSION - this.keyId = keyId! - this.iteration = iteration! - this.ciphertext = ciphertextBuffer - this.signature = signature - } - } - - public getKeyId(): number { - return this.keyId - } - - public getIteration(): number { - return this.iteration - } - - public getCipherText(): Uint8Array { - return this.ciphertext - } - - public verifySignature(signatureKey: Uint8Array): void { - const part1 = this.serialized.slice(0, this.serialized.length - this.SIGNATURE_LENGTH) - const part2 = this.serialized.slice(-1 * this.SIGNATURE_LENGTH) - const res = verifySignature(signatureKey, part1, part2) - if (!res) throw new Error('Invalid signature!') - } - - private getSignature(signatureKey: Uint8Array, serialized: Uint8Array): Uint8Array { - return Buffer.from(calculateSignature(signatureKey, serialized)) - } - - public serialize(): Uint8Array { - return this.serialized - } - - public getType(): number { - return 4 - } -} diff --git a/src/Signal/Group/sender-key-name.ts b/src/Signal/Group/sender-key-name.ts deleted file mode 100644 index 09486876b96..00000000000 --- a/src/Signal/Group/sender-key-name.ts +++ /dev/null @@ -1,66 +0,0 @@ -interface Sender { - id: string - deviceId: number - toString(): string -} - -function isNull(str: string | null): boolean { - return str === null || str === '' -} - -function intValue(num: number): number { - const MAX_VALUE = 0x7fffffff - const MIN_VALUE = -0x80000000 - if (num > MAX_VALUE || num < MIN_VALUE) { - return num & 0xffffffff - } - - return num -} - -function hashCode(strKey: string): number { - let hash = 0 - if (!isNull(strKey)) { - for (let i = 0; i < strKey.length; i++) { - hash = hash * 31 + strKey.charCodeAt(i) - hash = intValue(hash) - } - } - - return hash -} - -export class SenderKeyName { - private readonly groupId: string - private readonly sender: Sender - - constructor(groupId: string, sender: Sender) { - this.groupId = groupId - this.sender = sender - } - - public getGroupId(): string { - return this.groupId - } - - public getSender(): Sender { - return this.sender - } - - public serialize(): string { - return `${this.groupId}::${this.sender.id}::${this.sender.deviceId}` - } - - public toString(): string { - return this.serialize() - } - - public equals(other: SenderKeyName | null): boolean { - if (other === null) return false - return this.groupId === other.groupId && this.sender.toString() === other.sender.toString() - } - - public hashCode(): number { - return hashCode(this.groupId) ^ hashCode(this.sender.toString()) - } -} diff --git a/src/Signal/Group/sender-key-record.ts b/src/Signal/Group/sender-key-record.ts deleted file mode 100644 index dda30c1eb16..00000000000 --- a/src/Signal/Group/sender-key-record.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { BufferJSON } from '../../Utils/generics' -import { SenderKeyState } from './sender-key-state' - -export interface SenderKeyStateStructure { - senderKeyId: number - senderChainKey: { - iteration: number - seed: Uint8Array - } - senderSigningKey: { - public: Uint8Array - private?: Uint8Array - } - senderMessageKeys: Array<{ - iteration: number - seed: Uint8Array - }> -} - -export class SenderKeyRecord { - private readonly MAX_STATES = 5 - private readonly senderKeyStates: SenderKeyState[] = [] - - constructor(serialized?: SenderKeyStateStructure[]) { - if (serialized) { - for (const structure of serialized) { - this.senderKeyStates.push(new SenderKeyState(null, null, null, null, null, null, structure)) - } - } - } - - public isEmpty(): boolean { - return this.senderKeyStates.length === 0 - } - - public getSenderKeyState(keyId?: number): SenderKeyState | undefined { - if (keyId === undefined && this.senderKeyStates.length) { - return this.senderKeyStates[this.senderKeyStates.length - 1] - } - - return this.senderKeyStates.find(state => state.getKeyId() === keyId) - } - - public addSenderKeyState(id: number, iteration: number, chainKey: Uint8Array, signatureKey: Uint8Array): void { - this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, null, signatureKey)) - if (this.senderKeyStates.length > this.MAX_STATES) { - this.senderKeyStates.shift() - } - } - - public setSenderKeyState( - id: number, - iteration: number, - chainKey: Uint8Array, - keyPair: { public: Uint8Array; private: Uint8Array } - ): void { - this.senderKeyStates.length = 0 - this.senderKeyStates.push(new SenderKeyState(id, iteration, chainKey, keyPair)) - } - - public serialize(): SenderKeyStateStructure[] { - return this.senderKeyStates.map(state => state.getStructure()) - } - static deserialize(data: Uint8Array): SenderKeyRecord { - const str = Buffer.from(data).toString('utf-8') - const parsed = JSON.parse(str, BufferJSON.reviver) - return new SenderKeyRecord(parsed) - } -} diff --git a/src/Signal/Group/sender-key-state.ts b/src/Signal/Group/sender-key-state.ts deleted file mode 100644 index 412972200c2..00000000000 --- a/src/Signal/Group/sender-key-state.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { SenderChainKey } from './sender-chain-key' -import { SenderMessageKey } from './sender-message-key' - -interface SenderChainKeyStructure { - iteration: number - seed: Uint8Array -} - -interface SenderSigningKeyStructure { - public: Uint8Array - private?: Uint8Array -} - -interface SenderMessageKeyStructure { - iteration: number - seed: Uint8Array -} - -interface SenderKeyStateStructure { - senderKeyId: number - senderChainKey: SenderChainKeyStructure - senderSigningKey: SenderSigningKeyStructure - senderMessageKeys: SenderMessageKeyStructure[] -} - -export class SenderKeyState { - private readonly MAX_MESSAGE_KEYS = 2000 - private readonly senderKeyStateStructure: SenderKeyStateStructure - - constructor( - id?: number | null, - iteration?: number | null, - chainKey?: Uint8Array | null | string, - signatureKeyPair?: { public: Uint8Array | string; private: Uint8Array | string } | null, - signatureKeyPublic?: Uint8Array | string | null, - signatureKeyPrivate?: Uint8Array | string | null, - senderKeyStateStructure?: SenderKeyStateStructure | null - ) { - if (senderKeyStateStructure) { - this.senderKeyStateStructure = { - ...senderKeyStateStructure, - senderMessageKeys: Array.isArray(senderKeyStateStructure.senderMessageKeys) - ? senderKeyStateStructure.senderMessageKeys - : [] - } - } else { - if (signatureKeyPair) { - signatureKeyPublic = signatureKeyPair.public - signatureKeyPrivate = signatureKeyPair.private - } - - this.senderKeyStateStructure = { - senderKeyId: id || 0, - senderChainKey: { - iteration: iteration || 0, - seed: Buffer.from(chainKey || []) - }, - senderSigningKey: { - public: Buffer.from(signatureKeyPublic || []), - private: Buffer.from(signatureKeyPrivate || []) - }, - senderMessageKeys: [] - } - } - } - - public getKeyId(): number { - return this.senderKeyStateStructure.senderKeyId - } - - public getSenderChainKey(): SenderChainKey { - return new SenderChainKey( - this.senderKeyStateStructure.senderChainKey.iteration, - this.senderKeyStateStructure.senderChainKey.seed - ) - } - - public setSenderChainKey(chainKey: SenderChainKey): void { - this.senderKeyStateStructure.senderChainKey = { - iteration: chainKey.getIteration(), - seed: chainKey.getSeed() - } - } - - public getSigningKeyPublic(): Buffer { - const publicKey = Buffer.from(this.senderKeyStateStructure.senderSigningKey.public) - - if (publicKey.length === 32) { - const fixed = Buffer.alloc(33) - fixed[0] = 0x05 - publicKey.copy(fixed, 1) - return fixed - } - - return publicKey - } - - public getSigningKeyPrivate(): Buffer | undefined { - const privateKey = this.senderKeyStateStructure.senderSigningKey.private - - return Buffer.from(privateKey || []) - } - - public hasSenderMessageKey(iteration: number): boolean { - return this.senderKeyStateStructure.senderMessageKeys.some(key => key.iteration === iteration) - } - - public addSenderMessageKey(senderMessageKey: SenderMessageKey): void { - this.senderKeyStateStructure.senderMessageKeys.push({ - iteration: senderMessageKey.getIteration(), - seed: senderMessageKey.getSeed() - }) - - if (this.senderKeyStateStructure.senderMessageKeys.length > this.MAX_MESSAGE_KEYS) { - this.senderKeyStateStructure.senderMessageKeys.shift() - } - } - - public removeSenderMessageKey(iteration: number): SenderMessageKey | null { - const index = this.senderKeyStateStructure.senderMessageKeys.findIndex(key => key.iteration === iteration) - - if (index !== -1) { - const messageKey = this.senderKeyStateStructure.senderMessageKeys[index]! - this.senderKeyStateStructure.senderMessageKeys.splice(index, 1) - return new SenderMessageKey(messageKey.iteration, messageKey.seed) - } - - return null - } - - public getStructure(): SenderKeyStateStructure { - return this.senderKeyStateStructure - } -} diff --git a/src/Signal/Group/sender-message-key.ts b/src/Signal/Group/sender-message-key.ts deleted file mode 100644 index 7336a6e06d9..00000000000 --- a/src/Signal/Group/sender-message-key.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { deriveSecrets } from 'libsignal/src/crypto' - -export class SenderMessageKey { - private readonly iteration: number - private readonly iv: Uint8Array - private readonly cipherKey: Uint8Array - private readonly seed: Uint8Array - - constructor(iteration: number, seed: Uint8Array) { - const derivative = deriveSecrets(seed, Buffer.alloc(32), Buffer.from('WhisperGroup')) - const keys = new Uint8Array(32) - keys.set(new Uint8Array(derivative[0].slice(16))) - keys.set(new Uint8Array(derivative[1].slice(0, 16)), 16) - - this.iv = Buffer.from(derivative[0].slice(0, 16)) - this.cipherKey = Buffer.from(keys.buffer) - this.iteration = iteration - this.seed = seed - } - - public getIteration(): number { - return this.iteration - } - - public getIv(): Uint8Array { - return this.iv - } - - public getCipherKey(): Uint8Array { - return this.cipherKey - } - - public getSeed(): Uint8Array { - return this.seed - } -} diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index ea7b36f9689..b62646b85e9 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -1,7 +1,15 @@ -import * as libsignal from 'libsignal' import { LRUCache } from 'lru-cache' -import { ProtocolAddress, SessionBuilder, SessionCipher, SessionRecord } from 'whatsapp-rust-bridge/binary' -import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction, SignedKeyPair } from '../Types' +import { + GroupCipher, + GroupSessionBuilder, + ProtocolAddress, + SenderKeyDistributionMessage, + SenderKeyName, + SessionBuilder, + SessionCipher, + SessionRecord +} from 'whatsapp-rust-bridge/binary' +import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' import { generateSignalPubKey } from '../Utils' import type { ILogger } from '../Utils/logger' @@ -14,10 +22,6 @@ import { transferDevice, WAJIDDomains } 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( @@ -37,8 +41,8 @@ export function makeLibSignalRepository( const repository: SignalRepositoryWithLIDStore = { decryptGroupMessage({ group, authorJid, msg }) { - const senderName = jidToSignalSenderKeyName(group, authorJid) - const cipher = new GroupCipher(storage, senderName) + const senderAddr = new ProtocolAddress(jidDecode(authorJid)!.user, jidDecode(authorJid)!.device || 0) + const cipher = new GroupCipher(storage, group, senderAddr) // Use transaction to ensure atomicity return parsedKeys.transaction(async () => { @@ -51,27 +55,12 @@ export function makeLibSignalRepository( throw new Error('Group ID is required for sender key distribution message') } - const senderName = jidToSignalSenderKeyName(item.groupId, authorJid) + const senderAddr = new ProtocolAddress(jidDecode(authorJid)!.user, jidDecode(authorJid)!.device || 0) - 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()) - } + const senderName = new SenderKeyName(item.groupId, senderAddr) + const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage!) 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) }, @@ -113,20 +102,16 @@ export function makeLibSignalRepository( }, async encryptGroupMessage({ group, meId, data }) { - const senderName = jidToSignalSenderKeyName(group, meId) const builder = new GroupSessionBuilder(storage) + const meAddr = new ProtocolAddress(jidDecode(meId)!.user, jidDecode(meId)!.device || 0) + const senderName = new SenderKeyName(group, meAddr) - const senderNameStr = senderName.toString() + const senderKeyDistributionMessage = await builder.create(senderName) - return parsedKeys.transaction(async () => { - const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr]) - if (!senderKey) { - await storage.storeSenderKey(senderName, new SenderKeyRecord()) - } + const cipher = new GroupCipher(storage, group, meAddr) - const senderKeyDistributionMessage = await builder.create(senderName) - const session = new GroupCipher(storage, senderName) - const ciphertext = await session.encrypt(data) + return parsedKeys.transaction(async () => { + const ciphertext = await cipher.encrypt(data) return { ciphertext, @@ -158,10 +143,6 @@ export function makeLibSignalRepository( 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' } @@ -352,14 +333,7 @@ const jidToSignalProtocolAddress = (jid: string): ProtocolAddress => { return new ProtocolAddress(signalUser, finalDevice) } -const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => { - return new SenderKeyName(group, jidToSignalProtocolAddress(user)) -} - -function signalStorage( - { creds, keys }: SignalAuthState, - lidMapping: LIDMappingStore -): Omit & SenderKeyStore & { loadSignedPreKey: () => SignedKeyPair } { +function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingStore) { // Shared function to resolve PN signal address to LID if mapping exists const resolveLIDSignalAddress = async (id: string): Promise => { if (id.includes('.')) { @@ -386,10 +360,11 @@ function signalStorage( try { const wireJid = await resolveLIDSignalAddress(id) const { [wireJid]: sess } = await keys.get('session', [wireJid]) - if (sess) { - return SessionRecord.deserialize(sess) + return sess } + + return null } catch (e) { return null } @@ -421,16 +396,11 @@ function signalStorage( 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() + return key ?? null }, - storeSenderKey: async (senderKeyName: SenderKeyName, key: SenderKeyRecord) => { + storeSenderKey: async (senderKeyName: SenderKeyName, keyBytes: Uint8Array) => { const keyId = senderKeyName.toString() - const serialized = JSON.stringify(key.serialize()) - await keys.set({ 'sender-key': { [keyId]: Buffer.from(serialized, 'utf-8') } }) + await keys.set({ 'sender-key': { [keyId]: Buffer.from(keyBytes) } }) }, getOurRegistrationId: () => creds.registrationId, getOurIdentity: () => { diff --git a/src/__tests__/Signal/Group/sender-key-state-regression.test.ts b/src/__tests__/Signal/Group/sender-key-state-regression.test.ts deleted file mode 100644 index 9c49f62613a..00000000000 --- a/src/__tests__/Signal/Group/sender-key-state-regression.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { SenderKeyState } from '../../../Signal/Group/sender-key-state' -import { SenderMessageKey } from '../../../Signal/Group/sender-message-key' - -describe('SenderKeyState regression: missing senderMessageKeys array', () => { - it('should initialize senderMessageKeys when absent in provided structure', () => { - const legacyStructure = { - senderKeyId: 42, - senderChainKey: { iteration: 0, seed: Buffer.from([1, 2, 3]) }, - senderSigningKey: { public: Buffer.from([4, 5, 6]) } - } - - const state = new SenderKeyState(null, null, null, null, null, null, legacyStructure as any) - const msgKey = new SenderMessageKey(0, Buffer.from([7, 8, 9])) - state.addSenderMessageKey(msgKey) - - const structure = state.getStructure() - expect(structure.senderMessageKeys).toBeDefined() - expect(Array.isArray(structure.senderMessageKeys)).toBe(true) - expect(structure.senderMessageKeys.length).toBe(1) - expect(structure.senderMessageKeys[0]?.iteration).toBe(0) - }) -}) diff --git a/yarn.lock b/yarn.lock index 10a967f4935..1f8b4988378 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3025,7 +3025,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0-alpha.0" + whatsapp-rust-bridge: "npm:^0.4.0-alpha.1" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10185,10 +10185,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0-alpha.0": - version: 0.4.0-alpha.0 - resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.0" - checksum: 10c0/ab70e92fd1cd1be9e293ca8e951af2d4078c061611ed83befb86164236adbeb02760c563dc1e501c5b8ce12fd55334849fc691aa567934d27622336034c01688 +"whatsapp-rust-bridge@npm:^0.4.0-alpha.1": + version: 0.4.0-alpha.1 + resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.1" + checksum: 10c0/587fe80cd1564a82ef95219868f0c7b8e81508c980f2cf5dae75709a70a66aa15e1f673a463f5ea6d87f800b88940bde9f382ced04b1482730ef00f743042fbe languageName: node linkType: hard From b879c3ac315b504cc8fe1c53e96c15012983a8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sun, 16 Nov 2025 01:29:12 -0300 Subject: [PATCH 03/71] refactor: replace libsignal dependency with whatsapp-rust-bridge functions --- package.json | 1 - src/Utils/crypto.ts | 10 +++---- yarn.lock | 63 --------------------------------------------- 3 files changed, 5 insertions(+), 69 deletions(-) diff --git a/package.json b/package.json index cf74d21ca1c..2226eb17d0a 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node", "lru-cache": "^11.1.0", "music-metadata": "^11.7.0", "p-queue": "^9.0.0", diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 0e0dc2a1f45..29cbefc35f2 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -1,5 +1,5 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' -import * as curve from 'libsignal/src/curve' +import { calculateAgreement, calculateSignature, generateKeyPair, verifySignature } from 'whatsapp-rust-bridge/binary' import { KEY_BUNDLE_TYPE } from '../Defaults' import type { KeyPair } from '../Types' @@ -12,7 +12,7 @@ export const generateSignalPubKey = (pubKey: Uint8Array | Buffer) => export const Curve = { generateKeyPair: (): KeyPair => { - const { pubKey, privKey } = curve.generateKeyPair() + const { pubKey, privKey } = generateKeyPair() return { private: Buffer.from(privKey), // remove version byte @@ -20,13 +20,13 @@ export const Curve = { } }, sharedKey: (privateKey: Uint8Array, publicKey: Uint8Array) => { - const shared = curve.calculateAgreement(generateSignalPubKey(publicKey), privateKey) + const shared = calculateAgreement(generateSignalPubKey(publicKey), privateKey) return Buffer.from(shared) }, - sign: (privateKey: Uint8Array, buf: Uint8Array) => curve.calculateSignature(privateKey, buf), + sign: (privateKey: Uint8Array, buf: Uint8Array) => calculateSignature(privateKey, buf), verify: (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => { try { - curve.verifySignature(generateSignalPubKey(pubKey), message, signature) + verifySignature(generateSignalPubKey(pubKey), message, signature) return true } catch (error) { return false diff --git a/yarn.lock b/yarn.lock index 1f8b4988378..fa4e98970e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2102,13 +2102,6 @@ __metadata: languageName: node linkType: hard -"@types/long@npm:^4.0.0": - version: 4.0.2 - resolution: "@types/long@npm:4.0.2" - checksum: 10c0/42ec66ade1f72ff9d143c5a519a65efc7c1c77be7b1ac5455c530ae9acd87baba065542f8847522af2e3ace2cc999f3ad464ef86e6b7352eece34daf88f8c924 - languageName: node - linkType: hard - "@types/markdown-it@npm:^14.1.1": version: 14.1.2 resolution: "@types/markdown-it@npm:14.1.2" @@ -2142,13 +2135,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:^10.1.0": - version: 10.17.60 - resolution: "@types/node@npm:10.17.60" - checksum: 10c0/0742294912a6e79786cdee9ed77cff6ee8ff007b55d8e21170fc3e5994ad3a8101fea741898091876f8dc32b0a5ae3d64537b7176799e92da56346028d2cbcd2 - languageName: node - linkType: hard - "@types/node@npm:^20.9.0": version: 20.19.13 resolution: "@types/node@npm:20.19.13" @@ -3007,7 +2993,6 @@ __metadata: jimp: "npm:^1.6.0" jiti: "npm:^2.4.2" json: "npm:^11.0.0" - libsignal: "git+https://github.com/whiskeysockets/libsignal-node" link-preview-js: "npm:^3.0.0" lru-cache: "npm:^11.1.0" music-metadata: "npm:^11.7.0" @@ -3731,13 +3716,6 @@ __metadata: languageName: node linkType: hard -"curve25519-js@npm:^0.0.4": - version: 0.0.4 - resolution: "curve25519-js@npm:0.0.4" - checksum: 10c0/5b6c3a0dcaf045588aa78c2d1113310bf93fda9c59bd533b2a06da807024eec92feb39b203d1db9c09eda94bba1252d507fb3901283d32898e43090546785ddd - languageName: node - linkType: hard - "data-uri-to-buffer@npm:^4.0.0": version: 4.0.1 resolution: "data-uri-to-buffer@npm:4.0.1" @@ -6977,16 +6955,6 @@ __metadata: languageName: node linkType: hard -"libsignal@git+https://github.com/whiskeysockets/libsignal-node": - version: 2.0.1 - resolution: "libsignal@https://github.com/whiskeysockets/libsignal-node.git#commit=e81ecfc32eb74951d789ab37f7e341ab66d5fff1" - dependencies: - curve25519-js: "npm:^0.0.4" - protobufjs: "npm:6.8.8" - checksum: 10c0/d1ae7d8a5fadd6bb1c486d1b2ebc388967fee57c13f52b473127c1cbd9cd647b44545ff07c2b9cc49b3dea4e25ccfcfece31c526fdbdbf065837c85d189e97a0 - languageName: node - linkType: hard - "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -7107,13 +7075,6 @@ __metadata: languageName: node linkType: hard -"long@npm:^4.0.0": - version: 4.0.0 - resolution: "long@npm:4.0.0" - checksum: 10c0/50a6417d15b06104dbe4e3d4a667c39b137f130a9108ea8752b352a4cfae047531a3ac351c181792f3f8768fe17cca6b0f406674a541a86fb638aaac560d83ed - languageName: node - linkType: hard - "long@npm:^5.0.0": version: 5.3.2 resolution: "long@npm:5.3.2" @@ -8405,30 +8366,6 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:6.8.8": - version: 6.8.8 - resolution: "protobufjs@npm:6.8.8" - dependencies: - "@protobufjs/aspromise": "npm:^1.1.2" - "@protobufjs/base64": "npm:^1.1.2" - "@protobufjs/codegen": "npm:^2.0.4" - "@protobufjs/eventemitter": "npm:^1.1.0" - "@protobufjs/fetch": "npm:^1.1.0" - "@protobufjs/float": "npm:^1.0.2" - "@protobufjs/inquire": "npm:^1.1.0" - "@protobufjs/path": "npm:^1.1.2" - "@protobufjs/pool": "npm:^1.1.0" - "@protobufjs/utf8": "npm:^1.1.0" - "@types/long": "npm:^4.0.0" - "@types/node": "npm:^10.1.0" - long: "npm:^4.0.0" - bin: - pbjs: bin/pbjs - pbts: bin/pbts - checksum: 10c0/2511ed6089245b2102c333ac56190b104f8d8227972c00f041def8387abf841fded7b2cb7130063666b7bca84597a43005ea05c5f674132a0ddd5eb94a6e7916 - languageName: node - linkType: hard - "protobufjs@npm:^7.2.4": version: 7.5.3 resolution: "protobufjs@npm:7.5.3" From 1f2128fce1e2aba12b4a7fa7615e4e94622ac964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sun, 16 Nov 2025 01:35:31 -0300 Subject: [PATCH 04/71] fix: import Long type for improved type handling --- src/Utils/generics.ts | 1 + src/__tests__/Utils/sync-action-utils.test.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Utils/generics.ts b/src/Utils/generics.ts index 60788954f14..28575e287e8 100644 --- a/src/Utils/generics.ts +++ b/src/Utils/generics.ts @@ -2,6 +2,7 @@ import { Boom } from '@hapi/boom' import { createHash, randomBytes } from 'crypto' import { proto } from '../../WAProto/index.js' const baileysVersion = [2, 3000, 1032141294] +import type Long from 'long' import type { BaileysEventEmitter, BaileysEventMap, diff --git a/src/__tests__/Utils/sync-action-utils.test.ts b/src/__tests__/Utils/sync-action-utils.test.ts index 5c19d29ec56..fbf5430379f 100644 --- a/src/__tests__/Utils/sync-action-utils.test.ts +++ b/src/__tests__/Utils/sync-action-utils.test.ts @@ -2,7 +2,6 @@ import { jest } from '@jest/globals' import type { ILogger } from '../../Utils/logger' import { processContactAction } from '../../Utils/sync-action-utils' - describe('processContactAction', () => { const mockLogger: ILogger = { warn: jest.fn(), From 2c774f51aea85548c969be0269716c78071fa5b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sun, 16 Nov 2025 03:49:32 -0300 Subject: [PATCH 05/71] chore: update whatsapp-rust-bridge to version 0.4.0-alpha.2 --- package.json | 2 +- src/Signal/libsignal.ts | 41 ++++++++++++++++++++++++----------------- yarn.lock | 10 +++++----- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/package.json b/package.json index 2226eb17d0a..dee218d715a 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0-alpha.1", + "whatsapp-rust-bridge": "^0.4.0-alpha.2", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index b62646b85e9..dad492562e7 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -1,4 +1,5 @@ import { LRUCache } from 'lru-cache' +import type { SignalStorage } from 'whatsapp-rust-bridge/binary' import { GroupCipher, GroupSessionBuilder, @@ -11,7 +12,6 @@ import { } from 'whatsapp-rust-bridge/binary' import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' -import { generateSignalPubKey } from '../Utils' import type { ILogger } from '../Utils/logger' import { isHostedLidUser, @@ -97,7 +97,7 @@ export function makeLibSignalRepository( return parsedKeys.transaction(async () => { const { type: sigType, body } = await cipher.encrypt(data) const type = sigType === 3 ? 'pkmsg' : 'msg' - return { type, ciphertext: Buffer.from(body) } + return { type, ciphertext: body } }, jid) }, @@ -333,7 +333,7 @@ const jidToSignalProtocolAddress = (jid: string): ProtocolAddress => { return new ProtocolAddress(signalUser, finalDevice) } -function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingStore) { +function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingStore): SignalStorage { // Shared function to resolve PN signal address to LID if mapping exists const resolveLIDSignalAddress = async (id: string): Promise => { if (id.includes('.')) { @@ -368,8 +368,6 @@ function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingS } catch (e) { return null } - - return null }, storeSession: async (id: string, session: SessionRecord) => { const wireJid = await resolveLIDSignalAddress(id) @@ -378,36 +376,45 @@ function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingS isTrustedIdentity: () => { return true // todo: implement }, - loadPreKey: async (id: number | string) => { + loadPreKey: async (id: number) => { 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) + privKey: key.private, + pubKey: key.public } } }, removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), - loadSignedPreKey: () => { + loadSignedPreKey: async (id: number) => { const key = creds.signedPreKey - return key + if (!key || key.keyId !== id) { + return null + } + + return { + keyId: key.keyId, + signature: key.signature, + keyPair: { + pubKey: key.keyPair.public, + privKey: key.keyPair.private + } + } }, - loadSenderKey: async (senderKeyName: SenderKeyName) => { - const keyId = senderKeyName.toString() + loadSenderKey: async (keyId: string) => { const { [keyId]: key } = await keys.get('sender-key', [keyId]) return key ?? null }, - storeSenderKey: async (senderKeyName: SenderKeyName, keyBytes: Uint8Array) => { - const keyId = senderKeyName.toString() - await keys.set({ 'sender-key': { [keyId]: Buffer.from(keyBytes) } }) + storeSenderKey: async (keyId: string, keyBytes: Uint8Array) => { + await keys.set({ 'sender-key': { [keyId]: keyBytes.slice() } }) }, getOurRegistrationId: () => creds.registrationId, getOurIdentity: () => { const { signedIdentityKey } = creds return { - privKey: Buffer.from(signedIdentityKey.private), - pubKey: Buffer.from(generateSignalPubKey(signedIdentityKey.public)) + privKey: signedIdentityKey.private, + pubKey: signedIdentityKey.public } } } diff --git a/yarn.lock b/yarn.lock index fa4e98970e0..7c7230aa14c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0-alpha.1" + whatsapp-rust-bridge: "npm:^0.4.0-alpha.2" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0-alpha.1": - version: 0.4.0-alpha.1 - resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.1" - checksum: 10c0/587fe80cd1564a82ef95219868f0c7b8e81508c980f2cf5dae75709a70a66aa15e1f673a463f5ea6d87f800b88940bde9f382ced04b1482730ef00f743042fbe +"whatsapp-rust-bridge@npm:^0.4.0-alpha.2": + version: 0.4.0-alpha.2 + resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.2" + checksum: 10c0/9c0bbbd49785450db97253b722d935cb7c0d6e0f897f4c03c01a233062161ba79ef4d657dafccf1e463a915c463e8e6ec2d4621ffac19579b7b648faaa5365d8 languageName: node linkType: hard From 224b2a8e7b38eed6709beb94569266caa2d9e0ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 20 Nov 2025 03:50:49 -0300 Subject: [PATCH 06/71] fix: update whatsapp-rust-bridge to version 0.4.0-alpha.3 (migration session and sender keys) --- package.json | 2 +- src/Signal/libsignal.ts | 6 +++++- yarn.lock | 10 +++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index dee218d715a..c3c77d6221d 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0-alpha.2", + "whatsapp-rust-bridge": "^0.4.0-alpha.3", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index dad492562e7..bad6a919471 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -104,7 +104,7 @@ export function makeLibSignalRepository( async encryptGroupMessage({ group, meId, data }) { const builder = new GroupSessionBuilder(storage) const meAddr = new ProtocolAddress(jidDecode(meId)!.user, jidDecode(meId)!.device || 0) - const senderName = new SenderKeyName(group, meAddr) + const senderName = jidToSignalSenderKeyName(group, meId) const senderKeyDistributionMessage = await builder.create(senderName) @@ -333,6 +333,10 @@ const jidToSignalProtocolAddress = (jid: string): ProtocolAddress => { return new ProtocolAddress(signalUser, finalDevice) } +const jidToSignalSenderKeyName = (group: string, user: string): SenderKeyName => { + return new SenderKeyName(group, jidToSignalProtocolAddress(user)) +} + function signalStorage({ creds, keys }: SignalAuthState, lidMapping: LIDMappingStore): SignalStorage { // Shared function to resolve PN signal address to LID if mapping exists const resolveLIDSignalAddress = async (id: string): Promise => { diff --git a/yarn.lock b/yarn.lock index 7c7230aa14c..8172b70f33b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0-alpha.2" + whatsapp-rust-bridge: "npm:^0.4.0-alpha.3" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0-alpha.2": - version: 0.4.0-alpha.2 - resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.2" - checksum: 10c0/9c0bbbd49785450db97253b722d935cb7c0d6e0f897f4c03c01a233062161ba79ef4d657dafccf1e463a915c463e8e6ec2d4621ffac19579b7b648faaa5365d8 +"whatsapp-rust-bridge@npm:^0.4.0-alpha.3": + version: 0.4.0-alpha.3 + resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.3" + checksum: 10c0/616ca62c0612ba9a0e26e8711968bf1319b8d92a5fd146fa95822ca085e11bf2db5b1e38f9fae6d1d71d43fc91aacb7de4c67a4f0d2f54567ca07bad9d992749 languageName: node linkType: hard From 65725a6b2ca3e55b7294a8992e8e7e235644d61e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 21 Nov 2025 11:30:20 -0300 Subject: [PATCH 07/71] fix: update whatsapp-rust-bridge to version 0.4.0-alpha.4 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c3c77d6221d..c7b21de2d3b 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0-alpha.3", + "whatsapp-rust-bridge": "^0.4.0-alpha.4", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 8172b70f33b..044c8448c05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0-alpha.3" + whatsapp-rust-bridge: "npm:^0.4.0-alpha.4" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0-alpha.3": - version: 0.4.0-alpha.3 - resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.3" - checksum: 10c0/616ca62c0612ba9a0e26e8711968bf1319b8d92a5fd146fa95822ca085e11bf2db5b1e38f9fae6d1d71d43fc91aacb7de4c67a4f0d2f54567ca07bad9d992749 +"whatsapp-rust-bridge@npm:^0.4.0-alpha.4": + version: 0.4.0-alpha.4 + resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.4" + checksum: 10c0/10cd3e8d41595a74dc25d2e10aea5b3408905030d07458e0dc11746676785c8fbd3530da7508d0934e67c471cb35175f5ead2217e945e4498c5f0f95f61d2a77 languageName: node linkType: hard From a0cb521927c7026df994e1a2a1d99a98e7830100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Mon, 24 Nov 2025 09:27:55 -0300 Subject: [PATCH 08/71] chore: bump whatsapp-rust-bridge --- package.json | 2 +- src/Signal/libsignal.ts | 4 ++-- src/Utils/crypto.ts | 2 +- yarn.lock | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index c7b21de2d3b..dd5648a0112 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0-alpha.4", + "whatsapp-rust-bridge": "^0.4.0", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index bad6a919471..32439442774 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -1,5 +1,5 @@ import { LRUCache } from 'lru-cache' -import type { SignalStorage } from 'whatsapp-rust-bridge/binary' +import type { SignalStorage } from 'whatsapp-rust-bridge' import { GroupCipher, GroupSessionBuilder, @@ -9,7 +9,7 @@ import { SessionBuilder, SessionCipher, SessionRecord -} from 'whatsapp-rust-bridge/binary' +} from 'whatsapp-rust-bridge' import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' import type { ILogger } from '../Utils/logger' diff --git a/src/Utils/crypto.ts b/src/Utils/crypto.ts index 29cbefc35f2..b9b2cd1d00d 100644 --- a/src/Utils/crypto.ts +++ b/src/Utils/crypto.ts @@ -1,5 +1,5 @@ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from 'crypto' -import { calculateAgreement, calculateSignature, generateKeyPair, verifySignature } from 'whatsapp-rust-bridge/binary' +import { calculateAgreement, calculateSignature, generateKeyPair, verifySignature } from 'whatsapp-rust-bridge' import { KEY_BUNDLE_TYPE } from '../Defaults' import type { KeyPair } from '../Types' diff --git a/yarn.lock b/yarn.lock index 044c8448c05..2077566f9b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0-alpha.4" + whatsapp-rust-bridge: "npm:^0.4.0" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0-alpha.4": - version: 0.4.0-alpha.4 - resolution: "whatsapp-rust-bridge@npm:0.4.0-alpha.4" - checksum: 10c0/10cd3e8d41595a74dc25d2e10aea5b3408905030d07458e0dc11746676785c8fbd3530da7508d0934e67c471cb35175f5ead2217e945e4498c5f0f95f61d2a77 +"whatsapp-rust-bridge@npm:^0.4.0": + version: 0.4.0 + resolution: "whatsapp-rust-bridge@npm:0.4.0" + checksum: 10c0/e4d15eb2dc9aeb4a69caf4c775ea3e28f4b9a1d01bdc93ea18ebf5a8c90249312db15a9dbdc754920374b7295a9e157c72dee186919102e6aa1dca1b958f8109 languageName: node linkType: hard From 70052562ea47072e0936a40a2d9e6465fa20699c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 25 Nov 2025 13:15:07 -0300 Subject: [PATCH 09/71] fix: simplify address creation in group message handling --- src/Signal/libsignal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index 32439442774..c1db8316017 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -41,7 +41,7 @@ export function makeLibSignalRepository( const repository: SignalRepositoryWithLIDStore = { decryptGroupMessage({ group, authorJid, msg }) { - const senderAddr = new ProtocolAddress(jidDecode(authorJid)!.user, jidDecode(authorJid)!.device || 0) + const senderAddr = jidToSignalProtocolAddress(authorJid) const cipher = new GroupCipher(storage, group, senderAddr) // Use transaction to ensure atomicity @@ -55,7 +55,7 @@ export function makeLibSignalRepository( throw new Error('Group ID is required for sender key distribution message') } - const senderAddr = new ProtocolAddress(jidDecode(authorJid)!.user, jidDecode(authorJid)!.device || 0) + const senderAddr = jidToSignalProtocolAddress(authorJid) const senderName = new SenderKeyName(item.groupId, senderAddr) const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage!) @@ -103,7 +103,7 @@ export function makeLibSignalRepository( async encryptGroupMessage({ group, meId, data }) { const builder = new GroupSessionBuilder(storage) - const meAddr = new ProtocolAddress(jidDecode(meId)!.user, jidDecode(meId)!.device || 0) + const meAddr = jidToSignalProtocolAddress(meId) const senderName = jidToSignalSenderKeyName(group, meId) const senderKeyDistributionMessage = await builder.create(senderName) From 6d51078dc96731c6346a64a6e648d280079a10f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 25 Nov 2025 13:52:28 -0300 Subject: [PATCH 10/71] chore: update whatsapp-rust-bridge to version 0.4.1 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dd5648a0112..365bedba56e 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.0", + "whatsapp-rust-bridge": "^0.4.1", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 2077566f9b2..ee59544b84d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.0" + whatsapp-rust-bridge: "npm:^0.4.1" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.0": - version: 0.4.0 - resolution: "whatsapp-rust-bridge@npm:0.4.0" - checksum: 10c0/e4d15eb2dc9aeb4a69caf4c775ea3e28f4b9a1d01bdc93ea18ebf5a8c90249312db15a9dbdc754920374b7295a9e157c72dee186919102e6aa1dca1b958f8109 +"whatsapp-rust-bridge@npm:^0.4.1": + version: 0.4.1 + resolution: "whatsapp-rust-bridge@npm:0.4.1" + checksum: 10c0/094dcdae79eeaecab23af1da5b8b39e4d55fae78f804b72aefc4886382d532a6afa83de722ceacacdeeeaec8a657a8f3d2aa3e34d804524b23206bd884563fe9 languageName: node linkType: hard From b66cd670f382c54a166c384504cdbeebff7b7ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 26 Nov 2025 12:26:37 -0300 Subject: [PATCH 11/71] fix: improve error handling in message decryption --- src/Utils/decode-wa-message.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Utils/decode-wa-message.ts b/src/Utils/decode-wa-message.ts index dc13fa4ef0f..f3206dea9f4 100644 --- a/src/Utils/decode-wa-message.ts +++ b/src/Utils/decode-wa-message.ts @@ -319,7 +319,7 @@ export const decryptMessageNode = ( } else { fullMessage.message = msg } - } catch (err: any) { + } catch (err: unknown) { const errorContext = { key: fullMessage.key, err, @@ -332,7 +332,7 @@ export const decryptMessageNode = ( logger.error(errorContext, 'failed to decrypt message') fullMessage.messageStubType = proto.WebMessageInfo.StubType.CIPHERTEXT - fullMessage.messageStubParameters = [err.message.toString()] + fullMessage.messageStubParameters = [safeGetErrorMessage(err)] } } } @@ -353,3 +353,15 @@ function isSessionRecordError(error: any): boolean { const errorMessage = error?.message || error?.toString() || '' return DECRYPTION_RETRY_CONFIG.sessionRecordErrors.some(errorPattern => errorMessage.includes(errorPattern)) } + +function safeGetErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'object' && error !== null && 'message' in error) { + return String((error as any).message) + } + + return String(error) +} From 1124e4c100b4b2f97589f707fffaa6e7805360c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sun, 30 Nov 2025 23:48:55 -0300 Subject: [PATCH 12/71] chore: update whatsapp-rust-bridge to version 0.4.4 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 365bedba56e..dafe1485b76 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.1", + "whatsapp-rust-bridge": "^0.4.4", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index ee59544b84d..9210b8aa7cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.1" + whatsapp-rust-bridge: "npm:^0.4.4" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.1": - version: 0.4.1 - resolution: "whatsapp-rust-bridge@npm:0.4.1" - checksum: 10c0/094dcdae79eeaecab23af1da5b8b39e4d55fae78f804b72aefc4886382d532a6afa83de722ceacacdeeeaec8a657a8f3d2aa3e34d804524b23206bd884563fe9 +"whatsapp-rust-bridge@npm:^0.4.4": + version: 0.4.4 + resolution: "whatsapp-rust-bridge@npm:0.4.4" + checksum: 10c0/b50dd3950231beb905fd8fa63993f0eaa3e07b92d8e6cbd078cbf3dc3ae5c15197717373aca899684d48b122e90ef248322bbc1270c4914b2d66323d0b8ef7e5 languageName: node linkType: hard From 4d3a0d6dba6065668abc1f557c18649a09148bda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 4 Dec 2025 00:42:53 -0300 Subject: [PATCH 13/71] chore: update whatsapp-rust-bridge to version 0.4.5 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dafe1485b76..78c4d156528 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.4", + "whatsapp-rust-bridge": "^0.4.5", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 9210b8aa7cd..6fc6d293e06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.4" + whatsapp-rust-bridge: "npm:^0.4.5" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.4": - version: 0.4.4 - resolution: "whatsapp-rust-bridge@npm:0.4.4" - checksum: 10c0/b50dd3950231beb905fd8fa63993f0eaa3e07b92d8e6cbd078cbf3dc3ae5c15197717373aca899684d48b122e90ef248322bbc1270c4914b2d66323d0b8ef7e5 +"whatsapp-rust-bridge@npm:^0.4.5": + version: 0.4.5 + resolution: "whatsapp-rust-bridge@npm:0.4.5" + checksum: 10c0/72ac80ea32b790774ca674e2ec5cf1eefe7f992a32f1fbb5abea58ed1a013bbec3877c544c5d4d3cc859879b63d6b85e09ea9116b6890e8a5e74ae63755c8b50 languageName: node linkType: hard From 03a67e51ed33c150214a813c5a3aceb33037b95b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 4 Dec 2025 18:17:21 -0300 Subject: [PATCH 14/71] chore: update whatsapp-rust-bridge to version 0.4.6 and set logger in libsignal --- package.json | 2 +- src/Signal/libsignal.ts | 8 +++++++- yarn.lock | 10 +++++----- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 78c4d156528..4b1c96c6a5f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.5", + "whatsapp-rust-bridge": "^0.4.6", "ws": "^8.13.0" }, "devDependencies": { diff --git a/src/Signal/libsignal.ts b/src/Signal/libsignal.ts index c1db8316017..9e868ff056c 100644 --- a/src/Signal/libsignal.ts +++ b/src/Signal/libsignal.ts @@ -3,12 +3,14 @@ import type { SignalStorage } from 'whatsapp-rust-bridge' import { GroupCipher, GroupSessionBuilder, + hasLogger, ProtocolAddress, SenderKeyDistributionMessage, SenderKeyName, SessionBuilder, SessionCipher, - SessionRecord + SessionRecord, + setLogger } from 'whatsapp-rust-bridge' import type { LIDMapping, SignalAuthState, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' @@ -29,6 +31,10 @@ export function makeLibSignalRepository( logger: ILogger, pnToLIDFunc?: (jids: string[]) => Promise ): SignalRepositoryWithLIDStore { + if (!hasLogger()) { + setLogger(logger) + } + const lidMapping = new LIDMappingStore(auth.keys as SignalKeyStoreWithTransaction, logger, pnToLIDFunc) const storage = signalStorage(auth, lidMapping) diff --git a/yarn.lock b/yarn.lock index 6fc6d293e06..33a0c49bff1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.5" + whatsapp-rust-bridge: "npm:^0.4.6" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.5": - version: 0.4.5 - resolution: "whatsapp-rust-bridge@npm:0.4.5" - checksum: 10c0/72ac80ea32b790774ca674e2ec5cf1eefe7f992a32f1fbb5abea58ed1a013bbec3877c544c5d4d3cc859879b63d6b85e09ea9116b6890e8a5e74ae63755c8b50 +"whatsapp-rust-bridge@npm:^0.4.6": + version: 0.4.6 + resolution: "whatsapp-rust-bridge@npm:0.4.6" + checksum: 10c0/a828b64e7003516f18e3dbd010104cc680cbff24ec6d0b5223a9b5d2e0a2860b5444bd4deec2532148a475542680c7d743a743068c9321fd59ac1a244879e3b2 languageName: node linkType: hard From 8e7e2026c1c7dce2c425e64f40f6ca334894b9ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Fri, 5 Dec 2025 09:34:11 -0300 Subject: [PATCH 15/71] chore: update whatsapp-rust-bridge to version 0.4.7 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 4b1c96c6a5f..dab3a756e9c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.6", + "whatsapp-rust-bridge": "^0.4.7", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 33a0c49bff1..c507ac60927 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.6" + whatsapp-rust-bridge: "npm:^0.4.7" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.6": - version: 0.4.6 - resolution: "whatsapp-rust-bridge@npm:0.4.6" - checksum: 10c0/a828b64e7003516f18e3dbd010104cc680cbff24ec6d0b5223a9b5d2e0a2860b5444bd4deec2532148a475542680c7d743a743068c9321fd59ac1a244879e3b2 +"whatsapp-rust-bridge@npm:^0.4.7": + version: 0.4.7 + resolution: "whatsapp-rust-bridge@npm:0.4.7" + checksum: 10c0/7342f54e52254c23951d8a2097f351c34c2ae04ae6e29b2f73ba9992551e004eaf0c499cdffa17fbab47d2c06cff8652368345184a002f2cf928a7a19e851e78 languageName: node linkType: hard From 113a85b7d70a53e59f3f3147e425488bec0b2054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sun, 14 Dec 2025 19:00:29 -0300 Subject: [PATCH 16/71] chore: update whatsapp-rust-bridge to version 0.4.8 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index dab3a756e9c..5be72e41cff 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.7", + "whatsapp-rust-bridge": "^0.4.8", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index c507ac60927..656e76337d2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.7" + whatsapp-rust-bridge: "npm:^0.4.8" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.7": - version: 0.4.7 - resolution: "whatsapp-rust-bridge@npm:0.4.7" - checksum: 10c0/7342f54e52254c23951d8a2097f351c34c2ae04ae6e29b2f73ba9992551e004eaf0c499cdffa17fbab47d2c06cff8652368345184a002f2cf928a7a19e851e78 +"whatsapp-rust-bridge@npm:^0.4.8": + version: 0.4.8 + resolution: "whatsapp-rust-bridge@npm:0.4.8" + checksum: 10c0/a0859f9c42a76d58a0a4e67ac4fe0294cb723f2ae1833ca157405b8cbfaa76d0f0ca105586606ea7cfbc6776d57650a06ecad05fb521231b6c33bd2c40e01135 languageName: node linkType: hard From 0f964dd2ac32cc171ed6a7adc35c88a739ba900f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Mon, 15 Dec 2025 12:40:46 -0300 Subject: [PATCH 17/71] fix: force 99 deviceId to be hosted device --- src/Utils/signal.ts | 3 +- src/__tests__/Signal/libsignal.test.ts | 98 ++++++++++++++++++++ src/__tests__/Utils/signal-hosted.test.ts | 106 ++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/Signal/libsignal.test.ts create mode 100644 src/__tests__/Utils/signal-hosted.test.ts diff --git a/src/Utils/signal.ts b/src/Utils/signal.ts index fcc9d814320..41ed2e3fde9 100644 --- a/src/Utils/signal.ts +++ b/src/Utils/signal.ts @@ -154,7 +154,8 @@ export const extractDeviceJids = ( ((myUser !== user && myLid !== user) || myDevice !== device) && // either different user or if me user, not this device (device === 0 || !!keyIndex) // ensure that "key-index" is specified for "non-zero" devices, produces a bad req otherwise ) { - if (isHosted) { + // Device 99 must always be on the hosted domain + if (isHosted || device === 99) { domainType = domainType === WAJIDDomains.LID ? WAJIDDomains.HOSTED_LID : WAJIDDomains.HOSTED } diff --git a/src/__tests__/Signal/libsignal.test.ts b/src/__tests__/Signal/libsignal.test.ts new file mode 100644 index 00000000000..5f5c3640fec --- /dev/null +++ b/src/__tests__/Signal/libsignal.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState } from '../../Types' + +const logger = P({ level: 'silent' }) + +describe('jidToSignalProtocolAddress', () => { + // Create a minimal mock auth state + const mockAuth: SignalAuthState = { + creds: { + registrationId: 1234, + signedIdentityKey: { + public: new Uint8Array(32), + private: new Uint8Array(32) + }, + signedPreKey: { + keyId: 1, + keyPair: { + public: new Uint8Array(32), + private: new Uint8Array(32) + }, + signature: new Uint8Array(64) + } + }, + keys: { + get: async () => ({}), + set: async () => {}, + transaction: async (work: any) => await work(), + isInTransaction: () => false + } + } as any + + const repository = makeLibSignalRepository(mockAuth, logger) + + describe('device 99 validation', () => { + it('should accept :99@hosted for hosted PN devices', () => { + const jid = '5511999887766:99@hosted' + expect(() => repository.jidToSignalProtocolAddress(jid)).not.toThrow() + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toContain('5511999887766_128.99') + }) + + it('should accept :99@hosted.lid for hosted LID devices', () => { + const jid = '18217575229588:99@hosted.lid' + expect(() => repository.jidToSignalProtocolAddress(jid)).not.toThrow() + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toContain('18217575229588_129.99') + }) + + it('should reject :99@lid as invalid (must be @hosted.lid, not @lid)', () => { + const jid = '18217575229588:99@lid' + expect(() => repository.jidToSignalProtocolAddress(jid)).toThrow( + 'Unexpected non-hosted device JID with device 99' + ) + }) + + it('should reject :99@s.whatsapp.net as invalid', () => { + const jid = '5511999887766:99@s.whatsapp.net' + expect(() => repository.jidToSignalProtocolAddress(jid)).toThrow( + 'Unexpected non-hosted device JID with device 99' + ) + }) + + it('should reject :99@g.us as invalid', () => { + const jid = '123456789:99@g.us' + expect(() => repository.jidToSignalProtocolAddress(jid)).toThrow( + 'Unexpected non-hosted device JID with device 99' + ) + }) + }) + + describe('standard device validation', () => { + it('should handle regular PN JID without device', () => { + const jid = '5511999887766@s.whatsapp.net' + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toBe('5511999887766.0') + }) + + it('should handle LID JID without device', () => { + const jid = '18217575229588@lid' + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toBe('18217575229588_1.0') + }) + + it('should handle PN JID with companion device', () => { + const jid = '5511999887766:1@s.whatsapp.net' + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toBe('5511999887766.1') + }) + + it('should handle LID JID with companion device', () => { + const jid = '18217575229588:33@lid' + const result = repository.jidToSignalProtocolAddress(jid) + expect(result).toBe('18217575229588_1.33') + }) + }) +}) diff --git a/src/__tests__/Utils/signal-hosted.test.ts b/src/__tests__/Utils/signal-hosted.test.ts new file mode 100644 index 00000000000..3e8c234ca1b --- /dev/null +++ b/src/__tests__/Utils/signal-hosted.test.ts @@ -0,0 +1,106 @@ +import { extractDeviceJids } from '../../Utils/signal' +import { WAJIDDomains } from '../../WABinary' + +describe('extractDeviceJids Hosted Device Logic', () => { + const myJid = '11111111111@s.whatsapp.net' + const myLid = '22222222222@lid' + + it('should correctly convert PN user with device 99 to @hosted domain', () => { + const targetUser = '33333333333@s.whatsapp.net' + // Mock a USync result where isHosted is MISSING/false for device 99 + const mockResult = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 99, keyIndex: 1, isHosted: false }] + } + } + ] + + const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.device).toBe(99) + // Must be HOSTED (1), not WHATSAPP (0) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED) + expect(result[0]!.server).toBe('hosted') + }) + + it('should correctly convert LID user with device 99 to @hosted.lid domain', () => { + const targetUser = '44444444444@lid' + const mockResult = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 99, keyIndex: 1, isHosted: false }] + } + } + ] + + const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.device).toBe(99) + // Must be HOSTED_LID (129), not LID (1) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED_LID) + expect(result[0]!.server).toBe('hosted.lid') + }) + + it('should respect explicit isHosted flag for non-99 devices', () => { + const targetUser = '55555555555@s.whatsapp.net' + const mockResult = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 33, keyIndex: 1, isHosted: true }] + } + } + ] + + const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.device).toBe(33) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED) + expect(result[0]!.server).toBe('hosted') + }) + + it('should NOT force hosted domain for non-99 devices without isHosted flag', () => { + const targetUser = '66666666666@s.whatsapp.net' + const mockResult = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 33, keyIndex: 1, isHosted: false }] + } + } + ] + + const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.device).toBe(33) + // Should remain WHATSAPP (0) when isHosted=false + expect(result[0]!.domainType).toBe(WAJIDDomains.WHATSAPP) + expect(result[0]!.server).toBe('s.whatsapp.net') + }) + + it('should handle device 99 with explicit isHosted=true (redundant but safe)', () => { + const targetUser = '77777777777@lid' + const mockResult = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 99, keyIndex: 1, isHosted: true }] + } + } + ] + + const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.device).toBe(99) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED_LID) + expect(result[0]!.server).toBe('hosted.lid') + }) +}) From 89e3f75c140af4406a23e2613b160c540048844a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 8 Jan 2026 03:15:56 -0300 Subject: [PATCH 18/71] fix: update whatsapp-rust-bridge to version 0.5.0-alpha.1 --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 5be72e41cff..0f78fa4142c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "p-queue": "^9.0.0", "pino": "^9.6", "protobufjs": "^7.2.4", - "whatsapp-rust-bridge": "^0.4.8", + "whatsapp-rust-bridge": "^0.5.0-alpha.1", "ws": "^8.13.0" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 656e76337d2..d088334318b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3010,7 +3010,7 @@ __metadata: typedoc: "npm:^0.27.9" typedoc-plugin-markdown: "npm:4.4.2" typescript: "npm:^5.8.2" - whatsapp-rust-bridge: "npm:^0.4.8" + whatsapp-rust-bridge: "npm:^0.5.0-alpha.1" ws: "npm:^8.13.0" peerDependencies: audio-decode: ^2.1.3 @@ -10122,10 +10122,10 @@ __metadata: languageName: node linkType: hard -"whatsapp-rust-bridge@npm:^0.4.8": - version: 0.4.8 - resolution: "whatsapp-rust-bridge@npm:0.4.8" - checksum: 10c0/a0859f9c42a76d58a0a4e67ac4fe0294cb723f2ae1833ca157405b8cbfaa76d0f0ca105586606ea7cfbc6776d57650a06ecad05fb521231b6c33bd2c40e01135 +"whatsapp-rust-bridge@npm:^0.5.0-alpha.1": + version: 0.5.0-alpha.1 + resolution: "whatsapp-rust-bridge@npm:0.5.0-alpha.1" + checksum: 10c0/aa6eec3c95996ede72080ddacabe900424223c17e74db10fca0ffa0f028cfc8d74b483254c2f4e0650f4c6cc1eecee1227f060b1b2a56128a7d2205bbb6f4393 languageName: node linkType: hard From bbdeaa600d7fe9bd32dafd6e5e351b3586d411d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Sat, 17 Jan 2026 22:58:10 -0300 Subject: [PATCH 19/71] perf: binary serialization works better with lru-cache --- src/Utils/auth-utils.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Utils/auth-utils.ts b/src/Utils/auth-utils.ts index 529d6ed21b7..6dcb72630ad 100644 --- a/src/Utils/auth-utils.ts +++ b/src/Utils/auth-utils.ts @@ -1,7 +1,7 @@ -import NodeCache from '@cacheable/node-cache' import { AsyncLocalStorage } from 'async_hooks' import { Mutex } from 'async-mutex' import { randomBytes } from 'crypto' +import { LRUCache } from 'lru-cache' import PQueue from 'p-queue' import { DEFAULT_CACHE_TTLS } from '../Defaults' import type { @@ -38,13 +38,17 @@ export function makeCacheableSignalKeyStore( logger?: ILogger, _cache?: CacheStore ): SignalKeyStore { - const cache = - _cache || - new NodeCache({ - stdTTL: DEFAULT_CACHE_TTLS.SIGNAL_STORE, // 5 minutes - useClones: false, - deleteOnExpire: true - }) + const lruCache = new LRUCache({ + ttl: DEFAULT_CACHE_TTLS.SIGNAL_STORE * 1000, + ttlAutopurge: true + }) + + const cache: CacheStore = _cache ?? { + get: (key: string) => lruCache.get(key) as T | undefined, + set: (key, value) => void lruCache.set(key, value as SignalDataTypeMap[keyof SignalDataTypeMap]), + del: key => void lruCache.delete(key), + flushAll: () => lruCache.clear() + } // Mutex for protecting cache operations const cacheMutex = new Mutex() From 6f1e6c7585b9528b7cbf094900451599afb6dddf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 4 Aug 2026 19:59:54 -0300 Subject: [PATCH 20/71] fix: scope device domainType per device, guard SKDM input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on #2067. extractDeviceJids hoisted `domainType` to the user result, so the first hosted device (or device 99) rewrote the domain for every device that followed it in the same list — a plain PN device listed after a hosted one was addressed as @hosted. Scope it per device; regression test added, and it fails without the fix. Also: guard the sender key distribution message instead of asserting it non-null, drop two `any` casts in favour of real types, and correct the migratedSessionCache TTL comment (3 days, not 7). --- packages/baileys/src/Signal/libsignal.ts | 8 +++- .../baileys/src/Utils/decode-wa-message.ts | 2 +- packages/baileys/src/Utils/signal.ts | 5 +- .../src/__tests__/Signal/libsignal.test.ts | 6 ++- .../src/__tests__/Utils/signal-hosted.test.ts | 46 +++++++++++++++---- 5 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 4e4fdc76c6d..57ad18d24b3 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -127,7 +127,7 @@ export function makeLibSignalRepository( // call sites don't have to null-check the (publicly optional) method. const parsedKeys = auth.keys as SignalKeyStoreWithRecordTransaction const migratedSessionCache = new LRUCache({ - ttl: 3 * 24 * 60 * 60 * 1000, // 7 days + ttl: 3 * 24 * 60 * 60 * 1000, // 3 days ttlAutopurge: true, updateAgeOnGet: true }) @@ -159,8 +159,12 @@ export function makeLibSignalRepository( throw new Error('Group ID is required for sender key distribution message') } + if (!item.axolotlSenderKeyDistributionMessage) { + throw new Error('Sender key distribution message is required') + } + const senderName = jidToSignalSenderKeyName(item.groupId, authorJid) - const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage!) + const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage) const senderNameStr = senderName.toString() // The "ensure a SenderKeyRecord exists" check runs INSIDE the diff --git a/packages/baileys/src/Utils/decode-wa-message.ts b/packages/baileys/src/Utils/decode-wa-message.ts index 20bdd98f76d..32b94f3fb71 100644 --- a/packages/baileys/src/Utils/decode-wa-message.ts +++ b/packages/baileys/src/Utils/decode-wa-message.ts @@ -398,7 +398,7 @@ function safeGetErrorMessage(error: unknown): string { } if (typeof error === 'object' && error !== null && 'message' in error) { - return String((error as any).message) + return String((error as { message: unknown }).message) } return String(error) diff --git a/packages/baileys/src/Utils/signal.ts b/packages/baileys/src/Utils/signal.ts index ac8d89b656e..2eb3b2ce027 100644 --- a/packages/baileys/src/Utils/signal.ts +++ b/packages/baileys/src/Utils/signal.ts @@ -192,7 +192,7 @@ export const extractDeviceJids = ( const { devices, id } = userResult as { devices: ParsedDeviceInfo; id: string } const decoded = jidDecode(id)!, { user, server } = decoded - let { domainType } = decoded + const { domainType: userDomainType } = decoded const deviceList = devices?.deviceList as DeviceListData[] if (!Array.isArray(deviceList)) continue for (const { id: device, keyIndex, isHosted } of deviceList) { @@ -201,6 +201,9 @@ export const extractDeviceJids = ( ((myUser !== user && myLid !== user) || myDevice !== device) && // either different user or if me user, not this device (device === 0 || !!keyIndex) // ensure that "key-index" is specified for "non-zero" devices, produces a bad req otherwise ) { + // Scoped per device: hoisting this out of the loop let one hosted + // device rewrite the domain for every later device in the same list. + let domainType = userDomainType // Device 99 must always be on the hosted domain if (isHosted || device === 99) { domainType = domainType === WAJIDDomains.LID ? WAJIDDomains.HOSTED_LID : WAJIDDomains.HOSTED diff --git a/packages/baileys/src/__tests__/Signal/libsignal.test.ts b/packages/baileys/src/__tests__/Signal/libsignal.test.ts index 5f5c3640fec..0f54810fd73 100644 --- a/packages/baileys/src/__tests__/Signal/libsignal.test.ts +++ b/packages/baileys/src/__tests__/Signal/libsignal.test.ts @@ -26,10 +26,12 @@ describe('jidToSignalProtocolAddress', () => { keys: { get: async () => ({}), set: async () => {}, - transaction: async (work: any) => await work(), + transaction: async (work: () => Promise) => await work(), isInTransaction: () => false } - } as any + // Only the address helpers are exercised here, so the store side is a stub + // rather than a full SignalKeyStoreWithTransaction. + } as unknown as SignalAuthState const repository = makeLibSignalRepository(mockAuth, logger) diff --git a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts index 3e8c234ca1b..4661ae60c89 100644 --- a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts +++ b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts @@ -1,5 +1,6 @@ import { extractDeviceJids } from '../../Utils/signal' import { WAJIDDomains } from '../../WABinary' +import type { USyncQueryResultList } from '../../WAUSync' describe('extractDeviceJids Hosted Device Logic', () => { const myJid = '11111111111@s.whatsapp.net' @@ -8,7 +9,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { it('should correctly convert PN user with device 99 to @hosted domain', () => { const targetUser = '33333333333@s.whatsapp.net' // Mock a USync result where isHosted is MISSING/false for device 99 - const mockResult = [ + const mockResult: USyncQueryResultList[] = [ { id: targetUser, devices: { @@ -17,7 +18,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { } ] - const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + const result = extractDeviceJids(mockResult, myJid, myLid, false) expect(result).toHaveLength(1) expect(result[0]!.device).toBe(99) @@ -28,7 +29,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { it('should correctly convert LID user with device 99 to @hosted.lid domain', () => { const targetUser = '44444444444@lid' - const mockResult = [ + const mockResult: USyncQueryResultList[] = [ { id: targetUser, devices: { @@ -37,7 +38,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { } ] - const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + const result = extractDeviceJids(mockResult, myJid, myLid, false) expect(result).toHaveLength(1) expect(result[0]!.device).toBe(99) @@ -48,7 +49,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { it('should respect explicit isHosted flag for non-99 devices', () => { const targetUser = '55555555555@s.whatsapp.net' - const mockResult = [ + const mockResult: USyncQueryResultList[] = [ { id: targetUser, devices: { @@ -57,7 +58,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { } ] - const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + const result = extractDeviceJids(mockResult, myJid, myLid, false) expect(result).toHaveLength(1) expect(result[0]!.device).toBe(33) @@ -67,7 +68,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { it('should NOT force hosted domain for non-99 devices without isHosted flag', () => { const targetUser = '66666666666@s.whatsapp.net' - const mockResult = [ + const mockResult: USyncQueryResultList[] = [ { id: targetUser, devices: { @@ -76,7 +77,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { } ] - const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + const result = extractDeviceJids(mockResult, myJid, myLid, false) expect(result).toHaveLength(1) expect(result[0]!.device).toBe(33) @@ -87,7 +88,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { it('should handle device 99 with explicit isHosted=true (redundant but safe)', () => { const targetUser = '77777777777@lid' - const mockResult = [ + const mockResult: USyncQueryResultList[] = [ { id: targetUser, devices: { @@ -96,11 +97,36 @@ describe('extractDeviceJids Hosted Device Logic', () => { } ] - const result = extractDeviceJids(mockResult as any, myJid, myLid, false) + const result = extractDeviceJids(mockResult, myJid, myLid, false) expect(result).toHaveLength(1) expect(result[0]!.device).toBe(99) expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED_LID) expect(result[0]!.server).toBe('hosted.lid') }) + + it('should not let a hosted device rewrite the domain of later devices in the same list', () => { + const targetUser = '88888888888@s.whatsapp.net' + const mockResult: USyncQueryResultList[] = [ + { + id: targetUser, + devices: { + deviceList: [ + { id: 99, keyIndex: 1, isHosted: true }, + { id: 1, keyIndex: 2, isHosted: false } + ] + } + } + ] + + const result = extractDeviceJids(mockResult, myJid, myLid, false) + + expect(result).toHaveLength(2) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED) + expect(result[0]!.server).toBe('hosted') + // The non-hosted device that follows must keep the user's own domain. + expect(result[1]!.device).toBe(1) + expect(result[1]!.domainType).toBe(WAJIDDomains.WHATSAPP) + expect(result[1]!.server).toBe('s.whatsapp.net') + }) }) From 3c8961239484346c0f00ab8ec9c8b9eface2cd74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 4 Aug 2026 20:22:23 -0300 Subject: [PATCH 21/71] fix(signal): honor signature results and keep pre-WASM sessions readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the backend-migration review on #2067. Curve.verify accepted every well-formed signature. The JS libsignal threw on a bad signature, so `try { verify(); return true }` was correct there; the bridge instead RETURNS false and only throws on malformed input, so the result was being discarded. Tampered account signatures and Noise certificate signatures both passed. Return the bridge's verdict. Sessions written before this branch are the JS libsignal object (`SessionRecord.serialize()` returned `{_sessions, version}`, never bytes). Two paths mishandled them: - migrateSession ran them through SessionRecord.deserialize, which deliberately flattens a legacy object to an EMPTY record. The PN → LID copy was therefore skipped and the session stranded behind the now-LID lookup. Move the record across verbatim instead. - loadSession handed the whole record to the bridge, whose converter takes `Object.keys(_sessions)[0]`. That is insertion-ordered, so after a ratchet rotation it is a CLOSED state and the upgraded session is unusable. Select the open state (the converter also accepts a bare entry) and pass that. getSessionInfo and validateSession read the legacy shape directly, so the retry protections cover unconverted sessions too. Also normalize the bridge's `DuplicatedMessage(...)` to MISSING_KEYS_ERROR_TEXT — messages-recv matches that text exactly to ACK a replayed stanza (487), so duplicates were driving retry/resend loops — and keep device 99 on HOSTED_LID when the user is already hosted-LID, instead of downgrading it into the PN namespace. Known gap: states beyond the open one are still dropped, because the published bridge writes `previous_sessions: []`. Preserving the backlog needs the bridge-side converter. --- packages/baileys/src/Signal/legacy-session.ts | 76 +++++++ packages/baileys/src/Signal/libsignal.ts | 87 +++++++- packages/baileys/src/Utils/crypto.ts | 6 +- packages/baileys/src/Utils/signal.ts | 7 +- .../Signal/duplicate-decrypt.test.ts | 123 +++++++++++ .../__tests__/Signal/legacy-session.test.ts | 206 ++++++++++++++++++ .../src/__tests__/Utils/curve-verify.test.ts | 57 +++++ .../src/__tests__/Utils/signal-hosted.test.ts | 20 ++ 8 files changed, 570 insertions(+), 12 deletions(-) create mode 100644 packages/baileys/src/Signal/legacy-session.ts create mode 100644 packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts create mode 100644 packages/baileys/src/__tests__/Signal/legacy-session.test.ts create mode 100644 packages/baileys/src/__tests__/Utils/curve-verify.test.ts diff --git a/packages/baileys/src/Signal/legacy-session.ts b/packages/baileys/src/Signal/legacy-session.ts new file mode 100644 index 00000000000..dcd74e02017 --- /dev/null +++ b/packages/baileys/src/Signal/legacy-session.ts @@ -0,0 +1,76 @@ +/** + * Auth states written before the WASM backend hold sessions in the JS libsignal + * shape — `SessionRecord.serialize()` returned a plain object, never bytes: + * + * { _sessions: { [baseKeyB64]: SessionEntry }, version: 'v1' } + * + * The bridge can migrate one of these, but it picks `Object.keys(_sessions)[0]`, + * which is insertion-ordered — after a ratchet rotation that is an old *closed* + * state, not the live one, so the upgraded session would be unusable. It also + * accepts a bare `SessionEntry` (anything carrying `registrationId` + + * `currentRatchet`), so selecting the open entry here hands it the right state. + */ + +export type LegacySessionEntry = { + registrationId?: number + currentRatchet?: unknown + indexInfo?: { + baseKey?: string + closed?: number + } +} + +export type LegacySessionRecord = { + _sessions?: { [baseKey: string]: LegacySessionEntry | undefined } + version?: string +} + +/** libsignal marks a live session with `closed === -1`. */ +const OPEN = -1 + +export const isLegacySessionRecord = (value: unknown): value is LegacySessionRecord => + typeof value === 'object' && value !== null && !ArrayBuffer.isView(value) && '_sessions' in value + +const isUsableEntry = (entry: LegacySessionEntry | undefined): entry is LegacySessionEntry => + !!entry && typeof entry.registrationId === 'number' && !!entry.currentRatchet + +/** + * The live session state, or undefined when every state is closed (or the + * record is empty). Mirrors libsignal's `getOpenSession()`. + */ +export const pickOpenLegacySession = (record: LegacySessionRecord): LegacySessionEntry | undefined => { + for (const entry of Object.values(record._sessions || {})) { + if (isUsableEntry(entry) && entry.indexInfo?.closed === OPEN) { + return entry + } + } + + return undefined +} + +export const hasOpenLegacySession = (record: LegacySessionRecord): boolean => !!pickOpenLegacySession(record) + +/** A single state, as handed to the bridge by `loadSession` for legacy records. */ +export const isLegacySessionEntry = (value: unknown): value is LegacySessionEntry => + typeof value === 'object' && + value !== null && + !ArrayBuffer.isView(value) && + 'registrationId' in value && + 'currentRatchet' in value + +/** + * `indexInfo.baseKey` is the JS libsignal equivalent of wacore's + * `alice_base_key`, so the retry protections keep working on a session that has + * not been rewritten into the bridge format yet. + */ +export const legacySessionInfo = ( + entry: LegacySessionEntry +): { baseKey: Uint8Array; registrationId: number } | null => { + const baseKey = entry.indexInfo?.baseKey + const { registrationId } = entry + if (!baseKey || typeof registrationId !== 'number') { + return null + } + + return { baseKey: new Uint8Array(Buffer.from(baseKey, 'base64')), registrationId } +} diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 57ad18d24b3..f13b7efa06e 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -1,3 +1,4 @@ +import { Boom } from '@hapi/boom' import { LRUCache } from 'lru-cache' import type { SignalStorage } from 'whatsapp-rust-bridge' import { @@ -22,6 +23,7 @@ import type { SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' +import { MISSING_KEYS_ERROR_TEXT } from '../Utils/decode-wa-message' import type { ILogger } from '../Utils/logger' import { isHostedLidUser, @@ -32,6 +34,13 @@ import { transferDevice, WAJIDDomains } from '../WABinary' +import { + hasOpenLegacySession, + isLegacySessionEntry, + isLegacySessionRecord, + legacySessionInfo, + pickOpenLegacySession +} from './legacy-session' import { LIDMappingStore } from './lid-mapping' /** @@ -65,6 +74,23 @@ async function resolveSignalAddressId(id: string, lidMapping: LIDMappingStore): return id } +/** + * The JS libsignal reported an already-consumed message key as + * `MISSING_KEYS_ERROR_TEXT`, which the receive path matches to ACK the stanza + * (487) instead of asking the peer to resend. The bridge rejects the same case + * with a Debug-formatted `DuplicatedMessage(chain, counter)` — and throws plain + * strings, not Errors — so map it back onto the text messages-recv understands. + * Without this a redelivered ciphertext drives a pointless retry/resend loop. + */ +function normalizeDecryptError(error: unknown): unknown { + const message = typeof error === 'string' ? error : error instanceof Error ? error.message : '' + if (message.includes('DuplicatedMessage')) { + return new Boom(MISSING_KEYS_ERROR_TEXT, { data: { cause: message } }) + } + + return error +} + /** Extract identity key from PreKeyWhisperMessage for identity change detection */ function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined { try { @@ -214,13 +240,17 @@ export function makeLibSignalRepository( async function doDecrypt() { let result: Uint8Array - switch (type) { - case 'pkmsg': - result = await session.decryptPreKeyWhisperMessage(ciphertext) - break - case 'msg': - result = await session.decryptWhisperMessage(ciphertext) - break + try { + switch (type) { + case 'pkmsg': + result = await session.decryptPreKeyWhisperMessage(ciphertext) + break + case 'msg': + result = await session.decryptWhisperMessage(ciphertext) + break + } + } catch (error) { + throw normalizeDecryptError(error) } return result @@ -287,6 +317,13 @@ export function makeLibSignalRepository( return null } + // A not-yet-converted session arrives as the JS libsignal state, which + // carries these fields directly — read them so the retry protections + // also cover sessions that predate the bridge format. + if (isLegacySessionEntry(serialized)) { + return legacySessionInfo(serialized) + } + // `storage.loadSession` hands the bridge the raw persisted record, and // `SessionRecord` only exposes `haveOpenSession`/`serialize` — no accessor // for the open state's fields. Decode the record here instead: it is the @@ -337,6 +374,12 @@ export function makeLibSignalRepository( return { exists: false, reason: 'no session' } } + // `loadSession` only yields a legacy entry when it found an OPEN one, + // and SessionRecord.deserialize would reject the object outright. + if (isLegacySessionEntry(serialized)) { + return { exists: true } + } + if (!SessionRecord.deserialize(serialized).haveOpenSession()) { return { exists: false, reason: 'no open session' } } @@ -499,6 +542,23 @@ export function makeLibSignalRepository( const pnSession = pnSessions[pnAddrStr] if (pnSession) { + // A pre-WASM auth state still holds the JS libsignal object here. + // Round-tripping it through SessionRecord.deserialize would flatten + // it to an EMPTY record, so the copy would be skipped and the PN + // session left stranded behind the now-LID-keyed lookup. Move the + // record across verbatim and let the storage adapter migrate it on + // first use, under the key it will actually be read from. + if (isLegacySessionRecord(pnSession)) { + if (hasOpenLegacySession(pnSession)) { + sessionUpdates[lidAddrStr] = pnSession as unknown as Uint8Array + sessionUpdates[pnAddrStr] = null + + migratedCount++ + } + + continue + } + // Session exists (guaranteed from device discovery) const fromSession = SessionRecord.deserialize(pnSession) if (fromSession.haveOpenSession()) { @@ -584,8 +644,19 @@ function signalStorage( try { const wireJid = await resolveLIDSignalAddress(id) const { [wireJid]: sess } = await keys.get('session', [wireJid]) + if (!sess) { + return null + } + + // Pre-WASM auth states hold the JS libsignal object. Hand the bridge + // the OPEN state rather than letting it take `_sessions`' first key, + // which after a rotation is a closed one. See ./legacy-session. + if (isLegacySessionRecord(sess)) { + const open = pickOpenLegacySession(sess) + return (open ?? sess) as unknown as Uint8Array + } - return sess ?? null + return sess } catch (e) { return null } diff --git a/packages/baileys/src/Utils/crypto.ts b/packages/baileys/src/Utils/crypto.ts index 35f6133ac60..ce37024ef80 100644 --- a/packages/baileys/src/Utils/crypto.ts +++ b/packages/baileys/src/Utils/crypto.ts @@ -27,8 +27,10 @@ export const Curve = { sign: (privateKey: Uint8Array, buf: Uint8Array) => calculateSignature(privateKey, buf), verify: (pubKey: Uint8Array, message: Uint8Array, signature: Uint8Array) => { try { - verifySignature(generateSignalPubKey(pubKey), message, signature) - return true + // The bridge reports a well-formed but invalid signature by returning + // false, and only throws on malformed input — so the result has to be + // returned, not just the absence of a throw. + return verifySignature(generateSignalPubKey(pubKey), message, signature) } catch (error) { return false } diff --git a/packages/baileys/src/Utils/signal.ts b/packages/baileys/src/Utils/signal.ts index 2eb3b2ce027..9f91eb8c2e7 100644 --- a/packages/baileys/src/Utils/signal.ts +++ b/packages/baileys/src/Utils/signal.ts @@ -204,9 +204,12 @@ export const extractDeviceJids = ( // Scoped per device: hoisting this out of the loop let one hosted // device rewrite the domain for every later device in the same list. let domainType = userDomainType - // Device 99 must always be on the hosted domain + // Device 99 must always be on the hosted domain. An already-hosted + // LID stays on the LID side: mapping it to plain HOSTED would move + // the JID into the PN namespace and address the wrong session. if (isHosted || device === 99) { - domainType = domainType === WAJIDDomains.LID ? WAJIDDomains.HOSTED_LID : WAJIDDomains.HOSTED + const isLidSide = domainType === WAJIDDomains.LID || domainType === WAJIDDomains.HOSTED_LID + domainType = isLidSide ? WAJIDDomains.HOSTED_LID : WAJIDDomains.HOSTED } extracted.push({ diff --git a/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts new file mode 100644 index 00000000000..5e8f15c0ef4 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' +import { MISSING_KEYS_ERROR_TEXT } from '../../Utils/decode-wa-message' + +const logger = P({ level: 'silent' }) + +const makeMemoryKeyStore = (): SignalKeyStore => { + const data: { [type: string]: { [id: string]: unknown } } = {} + + return { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + const value = bucket[id] + if (value !== undefined && value !== null) { + out[id] = value as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) { + delete data[type]![id] + } else { + data[type]![id] = value + } + } + } + } + } +} + +const makeParty = () => { + const creds = initAuthCreds() + const auth: SignalAuthState = { + creds, + keys: addTransactionCapability(makeMemoryKeyStore(), logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1 + }) + } + + return { auth, repository: makeLibSignalRepository(auth, logger) } +} + +/** + * messages-recv matches the decrypt failure text exactly to decide between + * ACKing a redelivered stanza (487) and asking the peer to resend. The bridge + * reports a replayed ciphertext as `DuplicatedMessage(chain, counter)` instead + * of the JS libsignal wording, so the repository normalizes it — otherwise + * every duplicate drives a retry loop. + */ +describe('decryptMessage duplicate handling', () => { + const bobJid = '5511900000002@s.whatsapp.net' + + const establish = async () => { + const alice = makeParty() + const bob = makeParty() + + // Alice opens a session towards Bob from Bob's published bundle. + const bobPreKeyId = 1 + const preKeyPair = initAuthCreds().signedPreKey.keyPair + await bob.auth.keys.set({ 'pre-key': { [bobPreKeyId]: preKeyPair } }) + + await alice.repository.injectE2ESession({ + jid: bobJid, + session: { + registrationId: bob.auth.creds.registrationId, + identityKey: generateSignalPubKey(bob.auth.creds.signedIdentityKey.public), + preKey: { keyId: bobPreKeyId, publicKey: generateSignalPubKey(preKeyPair.public) }, + signedPreKey: { + keyId: bob.auth.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(bob.auth.creds.signedPreKey.keyPair.public), + signature: bob.auth.creds.signedPreKey.signature + } + } as never + }) + + return { alice, bob } + } + + it('decrypts a message once and reports a replay as an already-used key', async () => { + const { alice, bob } = await establish() + const aliceJid = `${alice.auth.creds.registrationId}0000001@s.whatsapp.net` + + const plaintext = Buffer.from('hello bob') + const { type, ciphertext } = await alice.repository.encryptMessage({ jid: bobJid, data: plaintext }) + + // Happy path: Bob decrypts the first delivery. + const decrypted = await bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext }) + expect(Buffer.from(decrypted)).toEqual(plaintext) + + // Bad path: the very same ciphertext arrives again. + await expect(bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext })).rejects.toThrow( + MISSING_KEYS_ERROR_TEXT + ) + }) + + it('leaves unrelated decrypt failures untouched', async () => { + const { bob } = await establish() + const strangerJid = '5511900000009@s.whatsapp.net' + + // No session with this peer at all: must NOT be reported as a duplicate. + await expect( + bob.repository.decryptMessage({ + jid: strangerJid, + type: 'msg', + ciphertext: Buffer.from([0x33, 0x0a, 0x21, 0x05]) + }) + ).rejects.not.toThrow(MISSING_KEYS_ERROR_TEXT) + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts new file mode 100644 index 00000000000..ebe7192834f --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { + hasOpenLegacySession, + isLegacySessionEntry, + isLegacySessionRecord, + legacySessionInfo, + pickOpenLegacySession +} from '../../Signal/legacy-session' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' + +const logger = P({ level: 'silent' }) + +const b64 = (fill: number, size = 33) => Buffer.alloc(size, fill).toString('base64') + +/** One `SessionEntry.serialize()` as the JS libsignal wrote it (base64 strings). */ +const legacyEntry = ({ closed, registrationId, baseKeyFill }: Record) => ({ + registrationId, + currentRatchet: { + ephemeralKeyPair: { pubKey: b64(1), privKey: b64(2, 32) }, + lastRemoteEphemeralKey: b64(3), + previousCounter: 0, + rootKey: b64(4, 32) + }, + indexInfo: { + baseKey: b64(baseKeyFill!), + baseKeyType: 2, + closed, + used: 1700000000000, + created: 1699999999000, + remoteIdentityKey: b64(5) + }, + _chains: {} +}) + +/** A rotated record: the FIRST key is a closed state, the live one comes later. */ +const rotatedLegacyRecord = () => ({ + _sessions: { + [b64(9)]: legacyEntry({ closed: 1700000000000, registrationId: 111, baseKeyFill: 9 }), + [b64(8)]: legacyEntry({ closed: -1, registrationId: 222, baseKeyFill: 8 }) + }, + version: 'v1' +}) + +const makeMemoryKeyStore = (seed: { [type: string]: { [id: string]: unknown } } = {}) => { + const data: { [type: string]: { [id: string]: unknown } } = JSON.parse(JSON.stringify(seed)) + + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + const value = bucket[id] + if (value !== undefined && value !== null) { + out[id] = value as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) { + delete data[type]![id] + } else { + data[type]![id] = value + } + } + } + } + } + + return { store, data } +} + +const makeRepository = (seed?: { [type: string]: { [id: string]: unknown } }) => { + const { store, data } = makeMemoryKeyStore(seed) + const auth: SignalAuthState = { + creds: initAuthCreds(), + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + + return { repository: makeLibSignalRepository(auth, logger), data } +} + +describe('legacy session helpers', () => { + it('recognises a legacy record and rejects bridge bytes', () => { + expect(isLegacySessionRecord(rotatedLegacyRecord())).toBe(true) + expect(isLegacySessionRecord(new Uint8Array([1, 2, 3]))).toBe(false) + expect(isLegacySessionRecord(Buffer.from([1, 2, 3]))).toBe(false) + expect(isLegacySessionRecord(null)).toBe(false) + expect(isLegacySessionRecord({})).toBe(false) + }) + + it('picks the OPEN state, not the first key', () => { + const open = pickOpenLegacySession(rotatedLegacyRecord()) + + // Insertion order puts the closed state first; the live one must win. + expect(open?.registrationId).toBe(222) + expect(hasOpenLegacySession(rotatedLegacyRecord())).toBe(true) + }) + + it('reports no open state when every session is closed', () => { + const allClosed = { + _sessions: { + [b64(9)]: legacyEntry({ closed: 1700000000000, registrationId: 111, baseKeyFill: 9 }) + } + } + + expect(pickOpenLegacySession(allClosed)).toBeUndefined() + expect(hasOpenLegacySession(allClosed)).toBe(false) + }) + + it('ignores structurally incomplete entries', () => { + const broken = { _sessions: { a: { indexInfo: { closed: -1 } } as never } } + + expect(pickOpenLegacySession(broken)).toBeUndefined() + }) + + it('detects a bare entry and reads its session info', () => { + const entry = legacyEntry({ closed: -1, registrationId: 222, baseKeyFill: 8 }) + + expect(isLegacySessionEntry(entry)).toBe(true) + expect(isLegacySessionEntry(new Uint8Array([1]))).toBe(false) + + const info = legacySessionInfo(entry) + expect(info?.registrationId).toBe(222) + expect(Buffer.from(info!.baseKey).toString('base64')).toBe(b64(8)) + }) + + it('returns null session info when the entry lacks a base key', () => { + expect(legacySessionInfo({ registrationId: 1 })).toBeNull() + expect(legacySessionInfo({ indexInfo: { baseKey: b64(8) } })).toBeNull() + }) +}) + +describe('repository on a pre-WASM auth state', () => { + const pnJid = '5511900000001@s.whatsapp.net' + const addr = '5511900000001.0' + + it('exposes the OPEN legacy state through getSessionInfo', async () => { + const { repository } = makeRepository({ session: { [addr]: rotatedLegacyRecord() } }) + + const info = await repository.getSessionInfo(pnJid) + + // 222 is the live state; 111 is the stale closed one the bridge would take. + expect(info?.registrationId).toBe(222) + expect(Buffer.from(info!.baseKey).toString('base64')).toBe(b64(8)) + }) + + it('treats a legacy record with an open state as a valid session', async () => { + const { repository } = makeRepository({ session: { [addr]: rotatedLegacyRecord() } }) + + await expect(repository.validateSession(pnJid)).resolves.toEqual({ exists: true }) + }) + + it('reports no open session when the legacy record is fully closed', async () => { + const closedOnly = { + _sessions: { [b64(9)]: legacyEntry({ closed: 1700000000000, registrationId: 111, baseKeyFill: 9 }) } + } + const { repository } = makeRepository({ session: { [addr]: closedOnly } }) + + const result = await repository.validateSession(pnJid) + expect(result.exists).toBe(false) + }) + + it('carries a legacy session across the PN → LID migration instead of dropping it', async () => { + const lidJid = '18000000000001@lid' + const { repository, data } = makeRepository({ + 'device-list': { '5511900000001': ['0'] }, + session: { [addr]: rotatedLegacyRecord() } + }) + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + // PN row cleared, LID row now holds the record — still readable, not empty. + expect(data.session!['5511900000001.0']).toBeUndefined() + const moved = data.session!['18000000000001_1.0'] + expect(moved).toBeDefined() + expect(hasOpenLegacySession(moved as never)).toBe(true) + }) + + it('does not migrate a legacy record whose states are all closed', async () => { + const lidJid = '18000000000001@lid' + const closedOnly = { + _sessions: { [b64(9)]: legacyEntry({ closed: 1700000000000, registrationId: 111, baseKeyFill: 9 }) } + } + const { repository, data } = makeRepository({ + 'device-list': { '5511900000001': ['0'] }, + session: { [addr]: closedOnly } + }) + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(0) + // The dead record stays put rather than being copied onto the LID key. + expect(data.session!['18000000000001_1.0']).toBeUndefined() + }) +}) diff --git a/packages/baileys/src/__tests__/Utils/curve-verify.test.ts b/packages/baileys/src/__tests__/Utils/curve-verify.test.ts new file mode 100644 index 00000000000..f56ba1dc47a --- /dev/null +++ b/packages/baileys/src/__tests__/Utils/curve-verify.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from '@jest/globals' +import { Curve } from '../../Utils/crypto' + +/** + * The WASM bridge reports a well-formed but invalid signature by RETURNING + * false, and only throws on malformed input. The JS libsignal it replaced threw + * in both cases, so the old `try { verify(); return true }` shape silently + * accepted every 64-byte signature once the backend changed. These cases pin + * the contract: only a genuinely valid signature may return true. + */ +describe('Curve.verify', () => { + const message = Buffer.from('the quick brown fox') + + it('accepts a signature produced by the matching private key', () => { + const keyPair = Curve.generateKeyPair() + const signature = Curve.sign(keyPair.private, message) + + expect(Curve.verify(keyPair.public, message, signature)).toBe(true) + }) + + it('rejects a well-formed signature that does not match the message', () => { + const keyPair = Curve.generateKeyPair() + const signature = Curve.sign(keyPair.private, message) + + expect(Curve.verify(keyPair.public, Buffer.from('a different message'), signature)).toBe(false) + }) + + it('rejects a valid signature checked against the wrong public key', () => { + const signer = Curve.generateKeyPair() + const other = Curve.generateKeyPair() + const signature = Curve.sign(signer.private, message) + + expect(Curve.verify(other.public, message, signature)).toBe(false) + }) + + it('rejects a tampered signature of the correct length', () => { + const keyPair = Curve.generateKeyPair() + const signature = Buffer.from(Curve.sign(keyPair.private, message)) + signature[0] = signature[0]! ^ 0xff + + expect(signature).toHaveLength(64) + expect(Curve.verify(keyPair.public, message, signature)).toBe(false) + }) + + it('rejects an all-zero signature of the correct length', () => { + const keyPair = Curve.generateKeyPair() + + expect(Curve.verify(keyPair.public, message, new Uint8Array(64))).toBe(false) + }) + + it('rejects malformed input instead of throwing', () => { + const keyPair = Curve.generateKeyPair() + + expect(Curve.verify(keyPair.public, message, new Uint8Array(10))).toBe(false) + expect(Curve.verify(new Uint8Array(5), message, Curve.sign(keyPair.private, message))).toBe(false) + }) +}) diff --git a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts index 4661ae60c89..feb6ff4b411 100644 --- a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts +++ b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts @@ -105,6 +105,26 @@ describe('extractDeviceJids Hosted Device Logic', () => { expect(result[0]!.server).toBe('hosted.lid') }) + it('should keep an already-hosted LID user on the LID side for device 99', () => { + // jidDecode already yields HOSTED_LID here, so the device-99 rule must be a + // no-op rather than downgrading it into the PN hosted namespace. + const targetUser = '99999999999@hosted.lid' + const mockResult: USyncQueryResultList[] = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 99, keyIndex: 1, isHosted: false }] + } + } + ] + + const result = extractDeviceJids(mockResult, myJid, myLid, false) + + expect(result).toHaveLength(1) + expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED_LID) + expect(result[0]!.server).toBe('hosted.lid') + }) + it('should not let a hosted device rewrite the domain of later devices in the same list', () => { const targetUser = '88888888888@s.whatsapp.net' const mockResult: USyncQueryResultList[] = [ From 1f34024ae40a49c60285cd3ba80a35e690b52ed7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 4 Aug 2026 20:41:36 -0300 Subject: [PATCH 22/71] fix(signal): never revive a closed legacy session, keep live LID sessions Follow-up review on #2067. loadSession fell back to handing the bridge the whole legacy record when no state was open. The bridge then promotes `_sessions`' first key to the current session, so a closed state came back to life and encryption would run under a ratchet the peer had already dropped. Report "no session" instead and let it renegotiate. migrateSession copied a legacy PN record onto the LID key unconditionally. Before the legacy branch existed such records were skipped, which preserved a post-upgrade LID session by accident; with a real copy the check has to be explicit, so read the destination rows (already inside the lock scope) and leave a live LID session alone. Assert the duplicate-decrypt message by equality rather than substring: messages-recv compares it with `===`, so a prefix would break the 487 ACK while still passing toThrow. Also fix a comment claiming HOSTED is 1. --- packages/baileys/src/Signal/libsignal.ts | 36 +++++++++++++++++-- .../Signal/duplicate-decrypt.test.ts | 7 ++-- .../__tests__/Signal/legacy-session.test.ts | 32 +++++++++++++++++ .../src/__tests__/Utils/signal-hosted.test.ts | 2 +- 4 files changed, 72 insertions(+), 5 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index f13b7efa06e..71707786f83 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -91,6 +91,23 @@ function normalizeDecryptError(error: unknown): unknown { return error } +/** Does a stored session row — bridge bytes or a pre-WASM record — hold a live state? */ +function hasOpenSession(stored: Uint8Array | undefined): boolean { + if (!stored) { + return false + } + + if (isLegacySessionRecord(stored)) { + return hasOpenLegacySession(stored) + } + + try { + return SessionRecord.deserialize(stored).haveOpenSession() + } catch { + return false + } +} + /** Extract identity key from PreKeyWhisperMessage for identity change detection */ function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined { try { @@ -532,6 +549,10 @@ export function makeLibSignalRepository( // Bulk fetch PN sessions - already exist (verified during device discovery) const pnAddrStrings = Array.from(new Set(migrationOps.map(op => op.fromAddr.toString()))) const pnSessions = await parsedKeys.get('session', pnAddrStrings) + // Destination rows, needed to avoid overwriting a live LID session + // with a legacy PN one. Both sides are already inside the lock scope. + const lidAddrStrings = Array.from(new Set(migrationOps.map(op => op.toAddr.toString()))) + const lidSessions = await parsedKeys.get('session', lidAddrStrings) // Prepare bulk session updates (PN → LID migration + deletion) const sessionUpdates: { [key: string]: Uint8Array | null } = {} @@ -549,7 +570,12 @@ export function makeLibSignalRepository( // record across verbatim and let the storage adapter migrate it on // first use, under the key it will actually be read from. if (isLegacySessionRecord(pnSession)) { - if (hasOpenLegacySession(pnSession)) { + // A session established after the upgrade already lives on the + // LID key and is newer than anything the legacy PN record + // holds, so it wins. Before this branch existed the legacy + // record was silently skipped, which preserved it by accident; + // now that the copy is real the check has to be explicit. + if (hasOpenLegacySession(pnSession) && !hasOpenSession(lidSessions[lidAddrStr])) { sessionUpdates[lidAddrStr] = pnSession as unknown as Uint8Array sessionUpdates[pnAddrStr] = null @@ -651,9 +677,15 @@ function signalStorage( // Pre-WASM auth states hold the JS libsignal object. Hand the bridge // the OPEN state rather than letting it take `_sessions`' first key, // which after a rotation is a closed one. See ./legacy-session. + // + // With every state closed there is nothing safe to hand over: passing + // the record would let the bridge promote a closed state to the + // current session, and encrypting under a ratchet the peer already + // dropped yields messages nobody can decrypt. Report "no session" so + // the session is renegotiated instead. if (isLegacySessionRecord(sess)) { const open = pickOpenLegacySession(sess) - return (open ?? sess) as unknown as Uint8Array + return open ? (open as unknown as Uint8Array) : null } return sess diff --git a/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts index 5e8f15c0ef4..e8c96d3060b 100644 --- a/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts +++ b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts @@ -101,8 +101,11 @@ describe('decryptMessage duplicate handling', () => { const decrypted = await bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext }) expect(Buffer.from(decrypted)).toEqual(plaintext) - // Bad path: the very same ciphertext arrives again. - await expect(bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext })).rejects.toThrow( + // Bad path: the very same ciphertext arrives again. messages-recv compares + // the stub parameter with `===`, so a prefix/suffix would break the 487 ACK + // while still satisfying toThrow's substring match — assert exact equality. + await expect(bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext })).rejects.toHaveProperty( + 'message', MISSING_KEYS_ERROR_TEXT ) }) diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index ebe7192834f..2115e4f687e 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -187,6 +187,38 @@ describe('repository on a pre-WASM auth state', () => { expect(hasOpenLegacySession(moved as never)).toBe(true) }) + it('does not resurrect a fully-closed legacy record as a usable session', async () => { + const closedOnly = { + _sessions: { [b64(9)]: legacyEntry({ closed: 1700000000000, registrationId: 111, baseKeyFill: 9 }) } + } + const { repository } = makeRepository({ session: { [addr]: closedOnly } }) + + // Handing the record over would let the bridge promote the closed state to + // current and encrypt under a ratchet the peer already dropped. + await expect(repository.getSessionInfo(pnJid)).resolves.toBeNull() + await expect(repository.validateSession(pnJid)).resolves.toEqual({ exists: false, reason: 'no session' }) + }) + + it('keeps a live LID session instead of overwriting it with a legacy PN one', async () => { + const lidJid = '18000000000001@lid' + const lidAddr = '18000000000001_1.0' + const liveLid = { + _sessions: { [b64(7)]: legacyEntry({ closed: -1, registrationId: 333, baseKeyFill: 7 }) } + } + const { repository, data } = makeRepository({ + 'device-list': { '5511900000001': ['0'] }, + session: { [addr]: rotatedLegacyRecord(), [lidAddr]: liveLid } + }) + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(0) + // The post-upgrade LID session is newer and must survive untouched. + expect(pickOpenLegacySession(data.session![lidAddr] as never)?.registrationId).toBe(333) + // ...and the PN row is not cleared, since nothing was moved. + expect(data.session![addr]).toBeDefined() + }) + it('does not migrate a legacy record whose states are all closed', async () => { const lidJid = '18000000000001@lid' const closedOnly = { diff --git a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts index feb6ff4b411..29b7a7c28ec 100644 --- a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts +++ b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts @@ -22,7 +22,7 @@ describe('extractDeviceJids Hosted Device Logic', () => { expect(result).toHaveLength(1) expect(result[0]!.device).toBe(99) - // Must be HOSTED (1), not WHATSAPP (0) + // Must be HOSTED (128), not WHATSAPP (0) expect(result[0]!.domainType).toBe(WAJIDDomains.HOSTED) expect(result[0]!.server).toBe('hosted') }) From a28a7db52df2a3c3abd62ab2c514b512454462c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Tue, 4 Aug 2026 22:57:31 -0300 Subject: [PATCH 23/71] feat(signal): convert pre-WASM sessions through the core's typed model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The storage adapter's own legacy converter copies the libsignal message key in as a cipher key and zeroes the mac/iv. That material is a SEED the core re-derives the split from, so every session it converted failed its MAC on the first ciphertext the old build had enciphered. Port the core's typed interop boundary (importLegacySessionRecordV1 / projectLegacySessionRecordV1, behind the wacore legacy-session-interop feature) into the 0.5.x bridge and route loadSession through it. That also picks the OPEN state rather than `_sessions`' first key, which after a rotation is a closed one, and preserves the archived states the ad-hoc path dropped. The conversion is bidirectional, so the migration is no longer one-way: a projected session is readable by the pre-WASM build again. Evidence, from a session generated by baileys@7.0.0-rc.9 with no Rust in the pipeline (fixtures/legacy-session-rc9.json): - decrypts DM and group ciphertexts the JS backend left pending; - keeps the conversation going in both directions afterwards; - leaves the stored record untouched on read-only calls; - round-trips import -> project -> import to identical bytes; - the real rc.9 picks the projection back up and keeps sending — proving a rollback needs no re-pairing. Bridge side also updates wacore to the current main (waproto moved from prost to buffa) and fixes the resulting type errors. --- .../src/Signal/legacy-session-codec.ts | 181 ++ packages/baileys/src/Signal/libsignal.ts | 38 +- .../Signal/concurrent-session.test.ts | 214 ++ .../src/__tests__/Signal/legacy-codec.test.ts | 166 ++ .../__tests__/Signal/legacy-fixture.test.ts | 174 ++ .../__tests__/Signal/legacy-session.test.ts | 19 +- .../Signal/session-lost-update.test.ts | 199 ++ .../fixtures/legacy-session-rc9.json | 2270 +++++++++++++++++ .../src/__tests__/fixtures/rollback-step1.ts | 112 + packages/whatsapp-rust-bridge/Cargo.lock | 439 ++-- packages/whatsapp-rust-bridge/Cargo.toml | 46 +- .../src/legacy_session.rs | 482 ++++ packages/whatsapp-rust-bridge/src/lib.rs | 1 + .../src/protocol_address.rs | 4 +- .../src/session_cipher.rs | 4 +- .../src/storage_adapter.rs | 20 +- 16 files changed, 4152 insertions(+), 217 deletions(-) create mode 100644 packages/baileys/src/Signal/legacy-session-codec.ts create mode 100644 packages/baileys/src/__tests__/Signal/concurrent-session.test.ts create mode 100644 packages/baileys/src/__tests__/Signal/legacy-codec.test.ts create mode 100644 packages/baileys/src/__tests__/Signal/legacy-fixture.test.ts create mode 100644 packages/baileys/src/__tests__/Signal/session-lost-update.test.ts create mode 100644 packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json create mode 100644 packages/baileys/src/__tests__/fixtures/rollback-step1.ts create mode 100644 packages/whatsapp-rust-bridge/src/legacy_session.rs diff --git a/packages/baileys/src/Signal/legacy-session-codec.ts b/packages/baileys/src/Signal/legacy-session-codec.ts new file mode 100644 index 00000000000..02d6cdc1ca8 --- /dev/null +++ b/packages/baileys/src/Signal/legacy-session-codec.ts @@ -0,0 +1,181 @@ +/** + * Converts between the JS libsignal on-disk session JSON and the bridge's typed + * `LegacySessionRecordV1` model. + * + * The bridge deliberately knows nothing about Baileys' storage shape: it speaks + * the typed model, and the core owns the actual protocol translation (including + * the HKDF split that a raw field-by-field copy gets wrong). This module is the + * only place that understands the legacy JSON, and it goes BOTH ways — + * `toTypedRecord` for reading an upgraded store, `fromTypedRecord` for handing a + * session back in the shape the pre-WASM code can still read. + */ +import type { + LegacySessionChainV1, + LegacySessionMessageKeyV1, + LegacySessionRecordV1, + LegacySessionV1 +} from 'whatsapp-rust-bridge' +import type { LegacySessionRecord } from './legacy-session' + +/** libsignal chain roles, from `chain_type.js`. */ +const CHAIN_ROLE = { SENDING: 1, RECEIVING: 2 } as const + +type LegacyChainJson = { + chainKey?: { counter?: number; key?: string } + chainType?: number + messageKeys?: { [index: string]: string } +} + +type LegacyEntryJson = { + registrationId?: number + currentRatchet?: { + ephemeralKeyPair?: { pubKey?: string; privKey?: string } + lastRemoteEphemeralKey?: string + previousCounter?: number + rootKey?: string + } + indexInfo?: { + baseKey?: string + baseKeyType?: number + closed?: number + used?: number + created?: number + remoteIdentityKey?: string + } + _chains?: { [ratchetKey: string]: LegacyChainJson } + pendingPreKey?: { preKeyId?: number; signedKeyId?: number; baseKey?: string } +} + +const decode = (value: string | undefined, label: string): Uint8Array => { + if (typeof value !== 'string') { + throw new TypeError(`legacy session: ${label} must be base64 text`) + } + + return new Uint8Array(Buffer.from(value, 'base64')) +} + +const encode = (value: Uint8Array): string => Buffer.from(value).toString('base64') + +const toTypedChain = (ratchetKey: string, chain: LegacyChainJson): LegacySessionChainV1 => { + const messageKeys: LegacySessionMessageKeyV1[] = [] + for (const [index, seed] of Object.entries(chain.messageKeys || {})) { + // The stored value is the message-key SEED; the core re-derives the + // cipher/mac/iv split from it. Copying it in as a cipher key (and zeroing + // the rest) produces a session that fails its MAC on first use. + messageKeys.push({ index: Number(index), seed: decode(seed, `messageKeys[${index}]`) }) + } + + return { + ratchetKey: decode(ratchetKey, 'chain ratchetKey'), + role: chain.chainType ?? CHAIN_ROLE.RECEIVING, + chainKey: { + counter: chain.chainKey?.counter ?? 0, + key: chain.chainKey?.key ? decode(chain.chainKey.key, 'chainKey.key') : undefined + }, + messageKeys + } +} + +const toTypedSession = (entry: LegacyEntryJson): LegacySessionV1 => { + const ratchet = entry.currentRatchet + const index = entry.indexInfo + if (!ratchet || !index || typeof entry.registrationId !== 'number') { + throw new TypeError('legacy session: entry is missing registrationId/currentRatchet/indexInfo') + } + + return { + registrationId: entry.registrationId, + ratchet: { + keyPair: { + public: decode(ratchet.ephemeralKeyPair?.pubKey, 'ephemeralKeyPair.pubKey'), + private: decode(ratchet.ephemeralKeyPair?.privKey, 'ephemeralKeyPair.privKey') + }, + lastRemoteEphemeralKey: decode(ratchet.lastRemoteEphemeralKey, 'lastRemoteEphemeralKey'), + previousCounter: ratchet.previousCounter ?? 0, + rootKey: decode(ratchet.rootKey, 'rootKey') + }, + index: { + baseKey: decode(index.baseKey, 'indexInfo.baseKey'), + baseKeyRole: index.baseKeyType ?? 0, + closedTimestamp: index.closed ?? -1, + usedAtMs: index.used ?? 0, + createdAtMs: index.created ?? 0, + remoteIdentityKey: decode(index.remoteIdentityKey, 'indexInfo.remoteIdentityKey') + }, + chains: Object.entries(entry._chains || {}).map(([ratchetKey, chain]) => toTypedChain(ratchetKey, chain)), + pendingPreKey: entry.pendingPreKey + ? { + preKeyId: entry.pendingPreKey.preKeyId, + signedPreKeyId: entry.pendingPreKey.signedKeyId ?? 0, + baseKey: decode(entry.pendingPreKey.baseKey, 'pendingPreKey.baseKey') + } + : undefined + } +} + +/** Legacy on-disk JSON → the bridge's typed model. */ +export const toTypedRecord = (record: LegacySessionRecord): LegacySessionRecordV1 => { + const sessions = Object.entries(record._sessions || {}).map(([indexKey, entry]) => ({ + indexKey: decode(indexKey, 'session index key'), + session: toTypedSession(entry as LegacyEntryJson) + })) + + return { sessions } +} + +/** The bridge's typed model → legacy on-disk JSON, byte-for-byte comparable. */ +export const fromTypedRecord = (record: LegacySessionRecordV1): LegacySessionRecord => { + const sessions: Record = {} + for (const indexed of record.sessions) { + const s = indexed.session + const chains: Record = {} + for (const chain of s.chains) { + const messageKeys: Record = {} + for (const key of chain.messageKeys) { + messageKeys[String(key.index)] = encode(key.seed) + } + + chains[encode(chain.ratchetKey)] = { + chainKey: { + counter: chain.chainKey.counter, + ...(chain.chainKey.key ? { key: encode(chain.chainKey.key) } : {}) + }, + chainType: chain.role, + messageKeys + } + } + + sessions[encode(indexed.indexKey)] = { + registrationId: s.registrationId, + currentRatchet: { + ephemeralKeyPair: { + pubKey: encode(s.ratchet.keyPair.public), + privKey: encode(s.ratchet.keyPair.private) + }, + lastRemoteEphemeralKey: encode(s.ratchet.lastRemoteEphemeralKey), + previousCounter: s.ratchet.previousCounter, + rootKey: encode(s.ratchet.rootKey) + }, + indexInfo: { + baseKey: encode(s.index.baseKey), + baseKeyType: s.index.baseKeyRole, + closed: s.index.closedTimestamp, + used: s.index.usedAtMs, + created: s.index.createdAtMs, + remoteIdentityKey: encode(s.index.remoteIdentityKey) + }, + _chains: chains, + ...(s.pendingPreKey + ? { + pendingPreKey: { + preKeyId: s.pendingPreKey.preKeyId, + signedKeyId: s.pendingPreKey.signedPreKeyId, + baseKey: encode(s.pendingPreKey.baseKey) + } + } + : {}) + } + } + + return { _sessions: sessions as never, version: 'v1' } +} diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 71707786f83..1820d56cc18 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -5,6 +5,7 @@ import { GroupCipher, GroupSessionBuilder, hasLogger, + importLegacySessionRecordV1, ProtocolAddress, SenderKeyDistributionMessage, SenderKeyName, @@ -23,6 +24,7 @@ import type { SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' +import { generateSignalPubKey } from '../Utils/crypto' import { MISSING_KEYS_ERROR_TEXT } from '../Utils/decode-wa-message' import type { ILogger } from '../Utils/logger' import { @@ -34,13 +36,8 @@ import { transferDevice, WAJIDDomains } from '../WABinary' -import { - hasOpenLegacySession, - isLegacySessionEntry, - isLegacySessionRecord, - legacySessionInfo, - pickOpenLegacySession -} from './legacy-session' +import { hasOpenLegacySession, isLegacySessionEntry, isLegacySessionRecord, legacySessionInfo } from './legacy-session' +import { toTypedRecord } from './legacy-session-codec' import { LIDMappingStore } from './lid-mapping' /** @@ -674,18 +671,25 @@ function signalStorage( return null } - // Pre-WASM auth states hold the JS libsignal object. Hand the bridge - // the OPEN state rather than letting it take `_sessions`' first key, - // which after a rotation is a closed one. See ./legacy-session. + // Pre-WASM auth states hold the JS libsignal object. Convert it through + // the core's typed model, which reconstructs the message-key material + // from its seed. The adapter's own field-by-field fallback cannot: it + // stores the seed as a cipher key and zeroes the mac/iv, so the very + // first ciphertext the old build enciphered fails its MAC. // - // With every state closed there is nothing safe to hand over: passing - // the record would let the bridge promote a closed state to the - // current session, and encrypting under a ratchet the peer already - // dropped yields messages nobody can decrypt. Report "no session" so - // the session is renegotiated instead. + // With every state closed there is nothing safe to hand over — the + // converted record would promote a closed state to current, and + // encrypting under a ratchet the peer already dropped yields messages + // nobody can decrypt. Report "no session" so it renegotiates. if (isLegacySessionRecord(sess)) { - const open = pickOpenLegacySession(sess) - return open ? (open as unknown as Uint8Array) : null + if (!hasOpenLegacySession(sess)) { + return null + } + + return importLegacySessionRecordV1(toTypedRecord(sess), { + identityKey: generateSignalPubKey(creds.signedIdentityKey.public), + registrationId: creds.registrationId + }) } return sess diff --git a/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts b/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts new file mode 100644 index 00000000000..ea7334017a2 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds, makeCacheableSignalKeyStore } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' + +const logger = P({ level: 'silent' }) + +const makeMemoryKeyStore = (): SignalKeyStore => { + const data: { [type: string]: { [id: string]: unknown } } = {} + + return { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) { + out[id] = bucket[id] as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } +} + +/** Mirrors how a real socket is wired: cacheable store on top of a durable one. */ +const makeParty = (cacheable: boolean) => { + const creds = initAuthCreds() + const base = makeMemoryKeyStore() + const keys = addTransactionCapability(cacheable ? makeCacheableSignalKeyStore(base, logger) : base, logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1 + }) + const auth: SignalAuthState = { creds, keys } + + return { auth, creds, repository: makeLibSignalRepository(auth, logger) } +} + +const bundleOf = async (party: ReturnType, preKeyId: number) => { + const preKey = initAuthCreds().signedPreKey.keyPair + await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) + + return { + registrationId: party.creds.registrationId, + identityKey: generateSignalPubKey(party.creds.signedIdentityKey.public), + preKey: { keyId: preKeyId, publicKey: generateSignalPubKey(preKey.public) }, + signedPreKey: { + keyId: party.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(party.creds.signedPreKey.keyPair.public), + signature: party.creds.signedPreKey.signature + } + } +} + +/** + * pingpong drives encrypt and decrypt against the SAME peer session at once: + * the pong for message N is enciphered while message N+1 is being deciphered. + * If those two interleave badly the sending chain rewinds, and the peer rejects + * the result with "message with old counter" — which is exactly what the + * wabench server reported for this branch. + */ +describe('concurrent encrypt/decrypt on one session', () => { + const aliceJid = '5511900000001@s.whatsapp.net' + const bobJid = '5511900000002@s.whatsapp.net' + + const establish = async (cacheable: boolean) => { + const alice = makeParty(cacheable) + const bob = makeParty(cacheable) + await alice.repository.injectE2ESession({ jid: bobJid, session: (await bundleOf(bob, 1)) as never }) + + // One round trip so both sides hold a running session. + const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('open') }) + await bob.repository.decryptMessage({ jid: aliceJid, type: opener.type, ciphertext: opener.ciphertext }) + + return { alice, bob } + } + + it.each([ + ['plain store', false], + ['cacheable store', true] + ])('keeps the sending chain monotonic under load (%s)', async (_label, cacheable) => { + const { alice, bob } = await establish(cacheable as boolean) + + // Alice enciphers a burst concurrently, exactly like a client answering a + // stream of pings without awaiting each send. + const BURST = 40 + const sent = await Promise.all( + Array.from({ length: BURST }, (_, i) => + alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`m${i}`) }) + ) + ) + + // Every ciphertext must decrypt exactly once, in any order. + const seen = new Set() + for (const [i, message] of sent.entries()) { + const plaintext = await bob.repository.decryptMessage({ + jid: aliceJid, + type: message.type, + ciphertext: message.ciphertext + }) + const text = Buffer.from(plaintext).toString() + expect(seen.has(text)).toBe(false) + seen.add(text) + expect(text).toBe(`m${i}`) + } + + expect(seen.size).toBe(BURST) + }) + + it('survives encrypt racing against decrypt on the same session', async () => { + const { alice, bob } = await establish(true) + + // Bob sends towards Alice while Alice sends towards Bob: both sides run + // encrypt and decrypt on the same session concurrently. + const ROUNDS = 25 + const fromBob = await Promise.all( + Array.from({ length: ROUNDS }, (_, i) => + bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from(`b${i}`) }) + ) + ) + + const results = await Promise.all( + fromBob.map(async (incoming, i) => { + const [plaintext, outgoing] = await Promise.all([ + alice.repository.decryptMessage({ + jid: bobJid, + type: incoming.type, + ciphertext: incoming.ciphertext + }), + alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`pong${i}`) }) + ]) + + return { plaintext: Buffer.from(plaintext).toString(), outgoing } + }) + ) + + expect(results.map(r => r.plaintext).sort()).toEqual(fromBob.map((_, i) => `b${i}`).sort()) + + // Bob must be able to read every pong Alice produced while decrypting. + const pongs = new Set() + for (const { outgoing } of results) { + const plaintext = await bob.repository.decryptMessage({ + jid: aliceJid, + type: outgoing.type, + ciphertext: outgoing.ciphertext + }) + pongs.add(Buffer.from(plaintext).toString()) + } + + expect(pongs.size).toBe(ROUNDS) + }) +}) + +/** + * The server periodically re-opens the session with a fresh prekey message + * (6 per wabench run). Adopting it must not invalidate a pong that was being + * enciphered at the same moment — the peer still has the old state archived and + * expects to be able to read it. + */ +describe('session replacement mid-flight', () => { + const aliceJid = '5511900000001@s.whatsapp.net' + const bobJid = '5511900000002@s.whatsapp.net' + + it('keeps producing readable ciphertext while adopting an incoming prekey session', async () => { + const alice = makeParty(true) + const bob = makeParty(true) + await alice.repository.injectE2ESession({ jid: bobJid, session: (await bundleOf(bob, 1)) as never }) + + const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('open') }) + await bob.repository.decryptMessage({ jid: aliceJid, type: opener.type, ciphertext: opener.ciphertext }) + + // Bob re-establishes: he injects a brand new session towards Alice and + // sends a pkmsg on it, while Alice keeps enciphering pongs. + await bob.repository.injectE2ESession({ jid: aliceJid, session: (await bundleOf(alice, 2)) as never }) + const rekey = await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('rekey') }) + expect(rekey.type).toBe('pkmsg') + + const [pongBefore, plaintext, pongAfter] = await Promise.all([ + alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('pong-before') }), + alice.repository.decryptMessage({ jid: bobJid, type: rekey.type, ciphertext: rekey.ciphertext }), + alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('pong-after') }) + ]) + + expect(Buffer.from(plaintext).toString()).toBe('rekey') + + // Bob must read BOTH pongs: one may ride the archived session, one the new. + const first = await bob.repository.decryptMessage({ + jid: aliceJid, + type: pongBefore.type, + ciphertext: pongBefore.ciphertext + }) + expect(Buffer.from(first).toString()).toBe('pong-before') + + const second = await bob.repository.decryptMessage({ + jid: aliceJid, + type: pongAfter.type, + ciphertext: pongAfter.ciphertext + }) + expect(Buffer.from(second).toString()).toBe('pong-after') + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts new file mode 100644 index 00000000000..558c8ba5eff --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { importLegacySessionRecordV1, projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' +import { fromTypedRecord, toTypedRecord } from '../../Signal/legacy-session-codec' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' +import fixture from '../fixtures/legacy-session-rc9.json' + +const logger = P({ level: 'silent' }) + +const revive = (value: unknown): unknown => { + if (typeof value === 'object' && value !== null && (value as { type?: string }).type === 'Buffer') { + return Buffer.from((value as { data: string }).data, 'base64') + } + + if (Array.isArray(value)) return value.map(revive) + // Typed arrays are already binary — walking them would turn them into plain + // objects keyed by index. + if (ArrayBuffer.isView(value)) return value + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)])) + } + + return value +} + +const makeStore = (seed: Record>) => { + const data = revive(seed) as Record> + + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) { + out[id] = bucket[id] as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } + + return { store, data } +} + +const bobCreds = revive(fixture.bob.creds) as { + registrationId: number + signedIdentityKey: { public: Uint8Array } +} +type LegacyEntry = { + registrationId: number + currentRatchet: { rootKey: string; lastRemoteEphemeralKey: string } + indexInfo: { baseKey: string; remoteIdentityKey: string; closed: number } + _chains: Record +} + +const legacyBobSession = fixture.bob.store.session['5511900000001.0'] as unknown as { + _sessions: Record + version: string +} +const { aliceJid } = fixture.jids + +const importBobSession = () => + importLegacySessionRecordV1(toTypedRecord(legacyBobSession as never), { + identityKey: generateSignalPubKey(bobCreds.signedIdentityKey.public), + registrationId: bobCreds.registrationId + }) + +describe('legacy session codec', () => { + it('imports a real rc.9 session into the native format', () => { + const bytes = importBobSession() + + expect(bytes.length).toBeGreaterThan(0) + }) + + it('decrypts rc.9 ciphertexts through the typed import', async () => { + // Same ciphertexts the ad-hoc storage-adapter conversion fails on: the + // typed path routes through the core, which reconstructs the message-key + // material properly instead of copying fields across. + const { store } = makeStore({ + ...(fixture.bob.store as unknown as Record>), + session: { '5511900000001.0': importBobSession() } + }) + const auth: SignalAuthState = { + creds: revive(fixture.bob.creds) as never, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + const repository = makeLibSignalRepository(auth, logger) + + for (const message of fixture.pending) { + const plaintext = await repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: Buffer.from(message.ct, 'base64') + }) + + expect(Buffer.from(plaintext).toString()).toBe(message.pt) + } + }) + + it('projects the native record back into readable legacy JSON', () => { + const projection = projectLegacySessionRecordV1(importBobSession()) + + expect(projection.status).toBe('projected') + if (projection.status !== 'projected') return + + const back = fromTypedRecord(projection.record) + const originalEntry = Object.values(legacyBobSession._sessions)[0]! + const backEntry = Object.values(back._sessions!)[0] as unknown as LegacyEntry + + // The identity of the session — what makes it the SAME session to the peer — + // must survive the round trip untouched. + expect(backEntry.registrationId).toBe(originalEntry.registrationId) + expect(backEntry.currentRatchet.rootKey).toBe(originalEntry.currentRatchet.rootKey) + expect(backEntry.currentRatchet.lastRemoteEphemeralKey).toBe(originalEntry.currentRatchet.lastRemoteEphemeralKey) + expect(backEntry.indexInfo.baseKey).toBe(originalEntry.indexInfo.baseKey) + expect(backEntry.indexInfo.remoteIdentityKey).toBe(originalEntry.indexInfo.remoteIdentityKey) + expect(backEntry.indexInfo.closed).toBe(originalEntry.indexInfo.closed) + }) + + it('preserves every ratchet chain across the round trip', () => { + const projection = projectLegacySessionRecordV1(importBobSession()) + if (projection.status !== 'projected') throw new Error('expected a projectable record') + + const back = fromTypedRecord(projection.record) + const originalEntry = Object.values(legacyBobSession._sessions)[0]! + const backEntry = Object.values(back._sessions!)[0] as unknown as LegacyEntry + + // Chain order is normalized by the core, so compare by ratchet key. + expect(Object.keys(backEntry._chains).sort()).toEqual(Object.keys(originalEntry._chains).sort()) + + for (const [ratchetKey, original] of Object.entries(originalEntry._chains)) { + const got = backEntry._chains[ratchetKey]! + expect(got.chainType).toBe(original.chainType) + expect(got.chainKey.counter).toBe(original.chainKey.counter) + expect(got.chainKey.key).toBe(original.chainKey.key) + } + }) + + it('is idempotent: re-importing the projection yields the same bytes', () => { + const first = importBobSession() + const projection = projectLegacySessionRecordV1(first) + if (projection.status !== 'projected') throw new Error('expected a projectable record') + + const second = importLegacySessionRecordV1(toTypedRecord(fromTypedRecord(projection.record) as never), { + identityKey: generateSignalPubKey(bobCreds.signedIdentityKey.public), + registrationId: bobCreds.registrationId + }) + + expect(Buffer.from(second)).toEqual(Buffer.from(first)) + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/legacy-fixture.test.ts b/packages/baileys/src/__tests__/Signal/legacy-fixture.test.ts new file mode 100644 index 00000000000..9dbea3e7147 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/legacy-fixture.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability } from '../../Utils/auth-utils' +import fixture from '../fixtures/legacy-session-rc9.json' + +const logger = P({ level: 'silent' }) + +/** + * `legacy-session-rc9.json` was produced by baileys@7.0.0-rc.9 — the last JS + * libsignal release, with no Rust anywhere in the pipeline. These are real + * ratcheted sessions (a DM that turned over twice, plus a group sender key) + * and real ciphertexts that the old implementation produced but never + * consumed. + * + * The point of these tests is the upgrade path: a user with an existing auth + * state must be able to install this branch and keep talking. If the WASM + * backend cannot decrypt what the JS backend enciphered, the migration silently + * breaks every live conversation. + */ + +type JsonBuffer = { type: 'Buffer'; data: string } + +const isJsonBuffer = (value: unknown): value is JsonBuffer => + typeof value === 'object' && value !== null && (value as JsonBuffer).type === 'Buffer' + +/** Rehydrate the `{type:'Buffer',data:}` envelopes back into Buffers. */ +const revive = (value: unknown): unknown => { + if (isJsonBuffer(value)) { + return Buffer.from(value.data, 'base64') + } + + if (Array.isArray(value)) { + return value.map(revive) + } + + if (typeof value === 'object' && value !== null) { + const out: Record = {} + for (const [k, v] of Object.entries(value)) { + out[k] = revive(v) + } + + return out + } + + return value +} + +const makeStore = (seed: Record>) => { + const data: Record> = revive(seed) as never + + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + const value = bucket[id] + if (value !== undefined && value !== null) { + out[id] = value as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) { + delete data[type]![id] + } else { + data[type]![id] = value + } + } + } + } + } + + return { store, data } +} + +const makeParty = (side: 'alice' | 'bob') => { + const { store, data } = makeStore(fixture[side].store as never) + const auth: SignalAuthState = { + creds: revive(fixture[side].creds) as never, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + + return { repository: makeLibSignalRepository(auth, logger), data } +} + +const { aliceJid, bobJid, groupJid } = fixture.jids + +describe('pre-WASM auth state (baileys@7.0.0-rc.9 fixture)', () => { + it('decrypts direct messages the JS backend enciphered but never delivered', async () => { + const bob = makeParty('bob') + + for (const message of fixture.pending) { + const plaintext = await bob.repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: Buffer.from(message.ct, 'base64') + }) + + expect(Buffer.from(plaintext).toString()).toBe(message.pt) + } + }) + + it('decrypts group messages using the legacy sender key', async () => { + const bob = makeParty('bob') + + for (const message of fixture.pendingGroup) { + const plaintext = await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: Buffer.from(message.ct, 'base64') + }) + + expect(Buffer.from(plaintext).toString()).toBe(message.pt) + } + }) + + it('keeps the conversation going after adopting the legacy session', async () => { + const alice = makeParty('alice') + const bob = makeParty('bob') + + // Alice (legacy session, now driven by the WASM backend) sends anew... + const outgoing = await alice.repository.encryptMessage({ + jid: bobJid, + data: Buffer.from('after the upgrade') + }) + const received = await bob.repository.decryptMessage({ + jid: aliceJid, + type: outgoing.type, + ciphertext: outgoing.ciphertext + }) + expect(Buffer.from(received).toString()).toBe('after the upgrade') + + // ...and Bob replies on the same migrated session. + const reply = await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('reply') }) + const readBack = await alice.repository.decryptMessage({ + jid: bobJid, + type: reply.type, + ciphertext: reply.ciphertext + }) + expect(Buffer.from(readBack).toString()).toBe('reply') + }) + + it('reports the legacy session as valid and exposes its identity', async () => { + const bob = makeParty('bob') + + await expect(bob.repository.validateSession(aliceJid)).resolves.toEqual({ exists: true }) + + const info = await bob.repository.getSessionInfo(aliceJid) + expect(info).not.toBeNull() + expect(info!.registrationId).toBeGreaterThan(0) + expect(info!.baseKey.length).toBeGreaterThan(0) + }) + + it('leaves the stored record untouched when only reading it', async () => { + const bob = makeParty('bob') + const before = JSON.stringify(bob.data.session) + + await bob.repository.validateSession(aliceJid) + await bob.repository.getSessionInfo(aliceJid) + + // Read-only operations must not rewrite the row: a user who installs this + // branch and never sends a message keeps a session the old code can read. + expect(JSON.stringify(bob.data.session)).toBe(before) + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 2115e4f687e..8e45afcbaec 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -144,20 +144,15 @@ describe('repository on a pre-WASM auth state', () => { const pnJid = '5511900000001@s.whatsapp.net' const addr = '5511900000001.0' - it('exposes the OPEN legacy state through getSessionInfo', async () => { + // A legacy record only converts if its key material is real: these fixtures + // carry filler bytes, which are not valid curve points, so the typed import + // rejects them rather than adopting a session that cannot work. The happy + // path is covered against a genuine rc.9 session in legacy-fixture.test.ts. + it('refuses a legacy record whose key material is not valid', async () => { const { repository } = makeRepository({ session: { [addr]: rotatedLegacyRecord() } }) - const info = await repository.getSessionInfo(pnJid) - - // 222 is the live state; 111 is the stale closed one the bridge would take. - expect(info?.registrationId).toBe(222) - expect(Buffer.from(info!.baseKey).toString('base64')).toBe(b64(8)) - }) - - it('treats a legacy record with an open state as a valid session', async () => { - const { repository } = makeRepository({ session: { [addr]: rotatedLegacyRecord() } }) - - await expect(repository.validateSession(pnJid)).resolves.toEqual({ exists: true }) + await expect(repository.getSessionInfo(pnJid)).resolves.toBeNull() + await expect(repository.validateSession(pnJid)).resolves.toEqual({ exists: false, reason: 'no session' }) }) it('reports no open session when the legacy record is fully closed', async () => { diff --git a/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts b/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts new file mode 100644 index 00000000000..fb2a8dfafd5 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { + SignalAuthState, + SignalDataSet, + SignalDataTypeMap, + SignalKeyStore, + SignalKeyStoreWithTransaction +} from '../../Types' +import { addTransactionCapability, initAuthCreds, makeCacheableSignalKeyStore } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' + +const logger = P({ level: 'silent' }) + +const deferred = () => { + let resolve!: () => void + const promise = new Promise(r => { + resolve = r + }) + + return { promise, resolve } +} + +const makeMemoryKeyStore = (): SignalKeyStore => { + const data: { [type: string]: { [id: string]: unknown } } = {} + + return { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) { + out[id] = bucket[id] as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } +} + +const makeParty = () => { + const creds = initAuthCreds() + const keys = addTransactionCapability(makeCacheableSignalKeyStore(makeMemoryKeyStore(), logger), logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1 + }) + const auth: SignalAuthState = { creds, keys } + + return { auth, creds, keys: keys as SignalKeyStoreWithTransaction, repository: makeLibSignalRepository(auth, logger) } +} + +const bundleOf = async (party: ReturnType, preKeyId: number) => { + const preKey = initAuthCreds().signedPreKey.keyPair + await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) + + return { + registrationId: party.creds.registrationId, + identityKey: generateSignalPubKey(party.creds.signedIdentityKey.public), + preKey: { keyId: preKeyId, publicKey: generateSignalPubKey(preKey.public) }, + signedPreKey: { + keyId: party.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(party.creds.signedPreKey.keyPair.public), + signature: party.creds.signedPreKey.signature + } + } +} + +/** + * `relayMessage` wraps sending in `transaction(meId)`, and `encryptMessage` + * opens a nested `transactWith({session})` inside it. A nested transactWith + * shares the OUTER context: it takes the session lock, buffers its write into + * the outer accumulator, then RELEASES the lock — while the write is still + * uncommitted. + * + * The decrypt path runs outside that outer transaction. It can therefore take + * the just-released session lock, read the pre-encrypt session from the store, + * advance it and commit — and then the outer transaction commits the encrypt's + * buffered write on top, rewinding the chain. + * + * That rewind is what the peer reports as `message with old counter N / 0`, + * which is exactly what the wabench server logged for this branch under load. + */ +describe('session lost update between relayMessage and the decrypt path', () => { + const aliceJid = '5511900000001@s.whatsapp.net' + const bobJid = '5511900000002@s.whatsapp.net' + const meId = 'alice-device' + + const establish = async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: (await bundleOf(bob, 1)) as never }) + + const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('open') }) + await bob.repository.decryptMessage({ jid: aliceJid, type: opener.type, ciphertext: opener.ciphertext }) + + // Bob replies so Alice has something inbound to decrypt mid-send. + const inbound = await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('inbound') }) + + return { alice, bob, inbound } + } + + it('does not rewind the sending chain when a decrypt lands mid-transaction', async () => { + const { alice, bob, inbound } = await establish() + + const encryptDone = deferred() + const releaseOuter = deferred() + + // Mirror relayMessage: an outer transaction keyed by meId that stays open + // after the nested encrypt has already released the session lock. + const sending = alice.keys.transaction(async () => { + const outgoing = await alice.repository.encryptMessage({ + jid: bobJid, + data: Buffer.from('sent-inside-transaction') + }) + encryptDone.resolve() + await releaseOuter.promise + return outgoing + }, meId) + + // The decrypt path is NOT inside that transaction. + await encryptDone.promise + const plaintext = await alice.repository.decryptMessage({ + jid: bobJid, + type: inbound.type, + ciphertext: inbound.ciphertext + }) + expect(Buffer.from(plaintext).toString()).toBe('inbound') + + releaseOuter.resolve() + const outgoing = await sending + + // The peer must still be able to read what Alice sent. If the outer + // commit clobbered the session the decrypt advanced, this ciphertext + // rides a rewound chain and Bob rejects it. + const received = await bob.repository.decryptMessage({ + jid: aliceJid, + type: outgoing.type, + ciphertext: outgoing.ciphertext + }) + expect(Buffer.from(received).toString()).toBe('sent-inside-transaction') + }) + + it('keeps the session usable for the NEXT send after the interleave', async () => { + const { alice, bob, inbound } = await establish() + + const encryptDone = deferred() + const releaseOuter = deferred() + + const sending = alice.keys.transaction(async () => { + const outgoing = await alice.repository.encryptMessage({ + jid: bobJid, + data: Buffer.from('first') + }) + encryptDone.resolve() + await releaseOuter.promise + return outgoing + }, meId) + + await encryptDone.promise + await alice.repository.decryptMessage({ + jid: bobJid, + type: inbound.type, + ciphertext: inbound.ciphertext + }) + + releaseOuter.resolve() + const first = await sending + + // A follow-up send must continue the chain rather than restart it. + const second = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('second') }) + + const firstRead = await bob.repository.decryptMessage({ + jid: aliceJid, + type: first.type, + ciphertext: first.ciphertext + }) + expect(Buffer.from(firstRead).toString()).toBe('first') + + const secondRead = await bob.repository.decryptMessage({ + jid: aliceJid, + type: second.type, + ciphertext: second.ciphertext + }) + expect(Buffer.from(secondRead).toString()).toBe('second') + }) +}) diff --git a/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json b/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json new file mode 100644 index 00000000000..30fae004dea --- /dev/null +++ b/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json @@ -0,0 +1,2270 @@ +{ + "note": "Generated by baileys@7.0.0-rc.9 (JS libsignal, no Rust). Do not regenerate casually.", + "baileysVersion": "7.0.0-rc.9", + "jids": { + "aliceJid": "5511900000001@s.whatsapp.net", + "bobJid": "5511900000002@s.whatsapp.net", + "groupJid": "120363000000000001@g.us" + }, + "alice": { + "creds": { + "noiseKey": { + "private": { + "type": "Buffer", + "data": [ + 144, + 7, + 127, + 75, + 190, + 64, + 202, + 100, + 32, + 9, + 16, + 156, + 206, + 72, + 243, + 63, + 247, + 107, + 189, + 204, + 120, + 143, + 252, + 199, + 204, + 17, + 59, + 189, + 97, + 161, + 67, + 81 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 176, + 67, + 232, + 116, + 101, + 209, + 220, + 124, + 161, + 14, + 225, + 79, + 225, + 249, + 32, + 229, + 85, + 202, + 237, + 74, + 27, + 23, + 88, + 93, + 56, + 141, + 83, + 100, + 143, + 239, + 77, + 40 + ] + } + }, + "pairingEphemeralKeyPair": { + "private": { + "type": "Buffer", + "data": [ + 136, + 205, + 70, + 26, + 187, + 140, + 9, + 82, + 67, + 116, + 228, + 196, + 212, + 35, + 55, + 213, + 72, + 69, + 129, + 108, + 190, + 163, + 192, + 195, + 248, + 180, + 35, + 68, + 187, + 106, + 103, + 93 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 229, + 94, + 169, + 197, + 24, + 146, + 4, + 193, + 246, + 212, + 126, + 245, + 39, + 36, + 186, + 162, + 196, + 57, + 159, + 170, + 153, + 235, + 132, + 186, + 186, + 52, + 188, + 106, + 232, + 156, + 152, + 15 + ] + } + }, + "signedIdentityKey": { + "private": { + "type": "Buffer", + "data": [ + 96, + 195, + 120, + 100, + 204, + 254, + 133, + 133, + 244, + 18, + 235, + 0, + 99, + 136, + 88, + 143, + 66, + 108, + 57, + 195, + 13, + 120, + 45, + 230, + 23, + 207, + 172, + 14, + 254, + 23, + 47, + 100 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 163, + 65, + 97, + 229, + 211, + 186, + 54, + 214, + 80, + 129, + 14, + 30, + 69, + 125, + 49, + 222, + 49, + 250, + 50, + 163, + 201, + 7, + 199, + 93, + 47, + 53, + 232, + 51, + 237, + 75, + 168, + 11 + ] + } + }, + "signedPreKey": { + "keyPair": { + "private": { + "type": "Buffer", + "data": [ + 64, + 136, + 234, + 249, + 78, + 64, + 44, + 223, + 200, + 90, + 71, + 145, + 28, + 169, + 51, + 124, + 108, + 8, + 185, + 203, + 129, + 205, + 46, + 208, + 83, + 177, + 140, + 159, + 136, + 53, + 9, + 103 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 163, + 44, + 252, + 229, + 128, + 1, + 22, + 169, + 98, + 147, + 148, + 127, + 242, + 108, + 60, + 73, + 171, + 122, + 118, + 165, + 1, + 12, + 204, + 160, + 38, + 116, + 241, + 221, + 190, + 168, + 82, + 51 + ] + } + }, + "signature": { + "type": "Buffer", + "data": [ + 12, + 64, + 234, + 101, + 192, + 27, + 59, + 140, + 12, + 99, + 220, + 32, + 5, + 190, + 30, + 127, + 213, + 68, + 70, + 0, + 10, + 178, + 36, + 128, + 51, + 232, + 154, + 181, + 69, + 202, + 110, + 223, + 165, + 143, + 92, + 26, + 88, + 45, + 60, + 0, + 250, + 182, + 156, + 16, + 29, + 18, + 67, + 102, + 0, + 160, + 142, + 84, + 26, + 232, + 35, + 46, + 199, + 204, + 252, + 170, + 126, + 157, + 222, + 2 + ] + }, + "keyId": 1 + }, + "registrationId": 28, + "advSecretKey": "lxoNl9OSvm7KXkfl2h0x7a/rHBW+0TUbBp/AKoY8mwg=", + "processedHistoryMessages": [], + "nextPreKeyId": 1, + "firstUnuploadedPreKeyId": 1, + "accountSyncCounter": 0, + "accountSettings": { + "unarchiveChats": false + }, + "registered": false + }, + "store": { + "session": { + "5511900000002.0": { + "_sessions": { + "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A": { + "registrationId": 100, + "currentRatchet": { + "ephemeralKeyPair": { + "pubKey": "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w", + "privKey": "2IHYeE9DDwVhbQm9lIPZEbHQZIgwfI/m2JVTIdq9LGA=" + }, + "lastRemoteEphemeralKey": "BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu", + "previousCounter": 0, + "rootKey": "DSpJ7D6WsqblAPci/vmTf1YC4A6bAFM4Z2P+CbW5otw=" + }, + "indexInfo": { + "baseKey": "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A", + "baseKeyType": 1, + "closed": -1, + "used": 1785888560561, + "created": 1785888560558, + "remoteIdentityKey": "BQvCrnGGKEpyRfCo9iIoh0LzUHb9PuIaSNAyLa95CEBY" + }, + "_chains": { + "BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu": { + "chainKey": { + "counter": 0, + "key": "rSmnP46RcJicySjrqHjKhr35rtCfWB+1nHrEBXBBLZ0=" + }, + "chainType": 2, + "messageKeys": {} + }, + "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w": { + "chainKey": { + "counter": 2, + "key": "JrE5qGwQQgh8PRvi2dHlEh3aS12mfqA7zLXU9yT1xHA=" + }, + "chainType": 1, + "messageKeys": {} + } + } + } + }, + "version": "v1" + } + }, + "sender-key": { + "120363000000000001@g.us::5511900000001::0": { + "type": "Buffer", + "data": [ + 91, + 123, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 75, + 101, + 121, + 73, + 100, + 34, + 58, + 50, + 49, + 49, + 48, + 54, + 52, + 50, + 53, + 50, + 52, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 67, + 104, + 97, + 105, + 110, + 75, + 101, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 53, + 44, + 34, + 115, + 101, + 101, + 100, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 54, + 48, + 44, + 51, + 51, + 44, + 49, + 56, + 57, + 44, + 49, + 57, + 52, + 44, + 49, + 53, + 50, + 44, + 56, + 49, + 44, + 49, + 57, + 44, + 49, + 51, + 52, + 44, + 49, + 49, + 57, + 44, + 49, + 51, + 49, + 44, + 49, + 57, + 44, + 57, + 48, + 44, + 57, + 54, + 44, + 49, + 57, + 52, + 44, + 50, + 51, + 51, + 44, + 49, + 48, + 44, + 57, + 56, + 44, + 49, + 49, + 56, + 44, + 54, + 44, + 49, + 48, + 57, + 44, + 57, + 57, + 44, + 49, + 50, + 55, + 44, + 51, + 57, + 44, + 49, + 57, + 44, + 53, + 54, + 44, + 56, + 50, + 44, + 49, + 51, + 55, + 44, + 49, + 53, + 52, + 44, + 53, + 55, + 44, + 54, + 44, + 51, + 44, + 49, + 48, + 56, + 93, + 125, + 125, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 83, + 105, + 103, + 110, + 105, + 110, + 103, + 75, + 101, + 121, + 34, + 58, + 123, + 34, + 112, + 117, + 98, + 108, + 105, + 99, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 53, + 44, + 50, + 49, + 48, + 44, + 53, + 44, + 49, + 52, + 52, + 44, + 49, + 49, + 48, + 44, + 50, + 53, + 44, + 49, + 48, + 56, + 44, + 49, + 51, + 44, + 49, + 57, + 56, + 44, + 50, + 52, + 57, + 44, + 50, + 49, + 48, + 44, + 49, + 55, + 52, + 44, + 50, + 50, + 50, + 44, + 48, + 44, + 50, + 52, + 55, + 44, + 51, + 49, + 44, + 49, + 55, + 49, + 44, + 49, + 54, + 57, + 44, + 49, + 56, + 55, + 44, + 49, + 50, + 56, + 44, + 49, + 53, + 49, + 44, + 49, + 57, + 50, + 44, + 57, + 50, + 44, + 49, + 51, + 54, + 44, + 50, + 48, + 49, + 44, + 52, + 53, + 44, + 49, + 55, + 50, + 44, + 54, + 49, + 44, + 49, + 52, + 52, + 44, + 50, + 49, + 54, + 44, + 53, + 54, + 44, + 50, + 53, + 44, + 49, + 51, + 93, + 125, + 44, + 34, + 112, + 114, + 105, + 118, + 97, + 116, + 101, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 51, + 50, + 44, + 49, + 49, + 57, + 44, + 52, + 52, + 44, + 54, + 49, + 44, + 49, + 55, + 50, + 44, + 50, + 53, + 51, + 44, + 50, + 52, + 49, + 44, + 53, + 44, + 49, + 50, + 51, + 44, + 57, + 55, + 44, + 54, + 57, + 44, + 49, + 51, + 57, + 44, + 50, + 48, + 54, + 44, + 50, + 48, + 50, + 44, + 49, + 48, + 55, + 44, + 50, + 49, + 53, + 44, + 56, + 55, + 44, + 55, + 44, + 50, + 51, + 44, + 49, + 56, + 55, + 44, + 49, + 49, + 48, + 44, + 56, + 48, + 44, + 50, + 48, + 53, + 44, + 55, + 52, + 44, + 50, + 50, + 44, + 50, + 53, + 53, + 44, + 50, + 52, + 53, + 44, + 50, + 49, + 49, + 44, + 53, + 48, + 44, + 51, + 44, + 50, + 54, + 44, + 55, + 50, + 93, + 125, + 125, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 77, + 101, + 115, + 115, + 97, + 103, + 101, + 75, + 101, + 121, + 115, + 34, + 58, + 91, + 123, + 34, + 105, + 116, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 49, + 44, + 34, + 115, + 101, + 101, + 100, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 55, + 56, + 44, + 53, + 52, + 44, + 55, + 48, + 44, + 49, + 54, + 53, + 44, + 55, + 48, + 44, + 54, + 49, + 44, + 56, + 52, + 44, + 49, + 48, + 48, + 44, + 49, + 51, + 54, + 44, + 57, + 56, + 44, + 50, + 49, + 44, + 56, + 53, + 44, + 49, + 48, + 53, + 44, + 57, + 56, + 44, + 49, + 48, + 51, + 44, + 49, + 53, + 57, + 44, + 50, + 48, + 51, + 44, + 49, + 52, + 56, + 44, + 49, + 54, + 52, + 44, + 49, + 51, + 56, + 44, + 49, + 52, + 57, + 44, + 49, + 53, + 49, + 44, + 49, + 54, + 51, + 44, + 50, + 51, + 53, + 44, + 55, + 51, + 44, + 49, + 56, + 44, + 56, + 54, + 44, + 54, + 54, + 44, + 50, + 48, + 51, + 44, + 49, + 54, + 54, + 44, + 49, + 54, + 44, + 52, + 53, + 93, + 125, + 125, + 44, + 123, + 34, + 105, + 116, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 51, + 44, + 34, + 115, + 101, + 101, + 100, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 49, + 53, + 55, + 44, + 55, + 50, + 44, + 49, + 55, + 51, + 44, + 57, + 54, + 44, + 53, + 51, + 44, + 50, + 50, + 55, + 44, + 49, + 50, + 51, + 44, + 49, + 55, + 44, + 56, + 53, + 44, + 50, + 53, + 48, + 44, + 55, + 51, + 44, + 49, + 50, + 53, + 44, + 56, + 52, + 44, + 50, + 52, + 53, + 44, + 54, + 53, + 44, + 49, + 54, + 49, + 44, + 50, + 51, + 49, + 44, + 50, + 57, + 44, + 52, + 48, + 44, + 57, + 44, + 50, + 52, + 55, + 44, + 49, + 56, + 44, + 49, + 51, + 57, + 44, + 49, + 54, + 50, + 44, + 56, + 51, + 44, + 49, + 57, + 55, + 44, + 49, + 57, + 55, + 44, + 53, + 57, + 44, + 49, + 56, + 54, + 44, + 50, + 50, + 49, + 44, + 50, + 53, + 48, + 44, + 57, + 48, + 93, + 125, + 125, + 93, + 125, + 93 + ] + } + } + } + }, + "bob": { + "creds": { + "noiseKey": { + "private": { + "type": "Buffer", + "data": [ + 216, + 60, + 178, + 186, + 185, + 3, + 97, + 53, + 122, + 237, + 205, + 239, + 24, + 39, + 137, + 83, + 76, + 115, + 96, + 49, + 170, + 226, + 134, + 147, + 211, + 58, + 144, + 62, + 34, + 43, + 165, + 124 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 226, + 55, + 2, + 122, + 8, + 214, + 23, + 0, + 240, + 190, + 205, + 209, + 71, + 59, + 147, + 176, + 11, + 72, + 132, + 170, + 69, + 11, + 27, + 226, + 204, + 191, + 238, + 23, + 1, + 98, + 38, + 109 + ] + } + }, + "pairingEphemeralKeyPair": { + "private": { + "type": "Buffer", + "data": [ + 48, + 48, + 141, + 222, + 99, + 84, + 211, + 254, + 55, + 8, + 139, + 77, + 195, + 44, + 155, + 209, + 141, + 178, + 97, + 53, + 127, + 239, + 216, + 77, + 203, + 182, + 20, + 217, + 51, + 209, + 13, + 124 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 250, + 202, + 27, + 62, + 43, + 63, + 201, + 41, + 121, + 52, + 138, + 157, + 155, + 250, + 235, + 208, + 84, + 233, + 180, + 139, + 221, + 90, + 127, + 96, + 21, + 4, + 20, + 125, + 186, + 20, + 22, + 28 + ] + } + }, + "signedIdentityKey": { + "private": { + "type": "Buffer", + "data": [ + 56, + 138, + 44, + 56, + 239, + 76, + 32, + 179, + 253, + 69, + 97, + 234, + 171, + 31, + 202, + 166, + 250, + 136, + 158, + 16, + 47, + 246, + 199, + 101, + 98, + 229, + 170, + 240, + 44, + 84, + 95, + 109 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 11, + 194, + 174, + 113, + 134, + 40, + 74, + 114, + 69, + 240, + 168, + 246, + 34, + 40, + 135, + 66, + 243, + 80, + 118, + 253, + 62, + 226, + 26, + 72, + 208, + 50, + 45, + 175, + 121, + 8, + 64, + 88 + ] + } + }, + "signedPreKey": { + "keyPair": { + "private": { + "type": "Buffer", + "data": [ + 32, + 196, + 57, + 135, + 21, + 199, + 66, + 180, + 22, + 65, + 95, + 205, + 225, + 170, + 215, + 58, + 17, + 135, + 175, + 83, + 85, + 200, + 181, + 118, + 108, + 251, + 183, + 173, + 158, + 160, + 85, + 88 + ] + }, + "public": { + "type": "Buffer", + "data": [ + 156, + 185, + 74, + 160, + 170, + 191, + 220, + 32, + 137, + 126, + 69, + 198, + 199, + 4, + 116, + 183, + 146, + 169, + 188, + 85, + 225, + 179, + 243, + 172, + 210, + 217, + 38, + 245, + 32, + 143, + 87, + 38 + ] + } + }, + "signature": { + "type": "Buffer", + "data": [ + 204, + 241, + 93, + 140, + 118, + 102, + 248, + 212, + 234, + 222, + 132, + 234, + 186, + 149, + 64, + 223, + 114, + 23, + 2, + 42, + 144, + 76, + 206, + 111, + 182, + 12, + 166, + 142, + 44, + 181, + 229, + 106, + 36, + 78, + 117, + 144, + 143, + 13, + 162, + 174, + 101, + 252, + 67, + 197, + 119, + 219, + 197, + 17, + 223, + 172, + 213, + 104, + 44, + 123, + 237, + 58, + 38, + 228, + 250, + 90, + 202, + 218, + 230, + 1 + ] + }, + "keyId": 1 + }, + "registrationId": 100, + "advSecretKey": "TucKh1/PV+F1KJDBv5U3zrNKwNtfIQkvrE9ijcEzPwA=", + "processedHistoryMessages": [], + "nextPreKeyId": 1, + "firstUnuploadedPreKeyId": 1, + "accountSyncCounter": 0, + "accountSettings": { + "unarchiveChats": false + }, + "registered": false + }, + "store": { + "pre-key": {}, + "session": { + "5511900000001.0": { + "_sessions": { + "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A": { + "registrationId": 28, + "currentRatchet": { + "ephemeralKeyPair": { + "pubKey": "Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y", + "privKey": "QNMOO65JPpWJ3lm+G6u4PWRjUgmXBYXVKhYt7qrd7Gs=" + }, + "lastRemoteEphemeralKey": "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w", + "previousCounter": 0, + "rootKey": "SEny8Z0Ae/SsRV8Z2YBHID8d21Qcr75QwsbqkNkLawo=" + }, + "indexInfo": { + "baseKey": "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A", + "baseKeyType": 2, + "closed": -1, + "used": 1785888560562, + "created": 1785888560560, + "remoteIdentityKey": "BaNBYeXTujbWUIEOHkV9Md4x+jKjyQfHXS816DPtS6gL" + }, + "_chains": { + "BWZkNHQDFNGwWjnHJZqCH5BVeSb0IRX9uBgiX8tZoo4G": { + "chainKey": { + "counter": 0 + }, + "chainType": 2, + "messageKeys": {} + }, + "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w": { + "chainKey": { + "counter": 0, + "key": "kThx60YHFhmq7wH/x1/ZN+842vfj//enBn1NnRWbxdc=" + }, + "chainType": 2, + "messageKeys": {} + }, + "Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y": { + "chainKey": { + "counter": -1, + "key": "2RFNlcpQPYZniWXeSTzl6EZVXEqvN5R9/Zwx3ZXMXJY=" + }, + "chainType": 1, + "messageKeys": {} + } + } + } + }, + "version": "v1" + } + }, + "sender-key": { + "120363000000000001@g.us::5511900000001::0": { + "type": "Buffer", + "data": [ + 91, + 123, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 75, + 101, + 121, + 73, + 100, + 34, + 58, + 50, + 49, + 49, + 48, + 54, + 52, + 50, + 53, + 50, + 52, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 67, + 104, + 97, + 105, + 110, + 75, + 101, + 121, + 34, + 58, + 123, + 34, + 105, + 116, + 101, + 114, + 97, + 116, + 105, + 111, + 110, + 34, + 58, + 49, + 44, + 34, + 115, + 101, + 101, + 100, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 55, + 50, + 44, + 50, + 48, + 51, + 44, + 53, + 55, + 44, + 49, + 57, + 49, + 44, + 50, + 53, + 51, + 44, + 54, + 48, + 44, + 50, + 48, + 49, + 44, + 50, + 51, + 49, + 44, + 49, + 57, + 57, + 44, + 50, + 52, + 57, + 44, + 49, + 48, + 56, + 44, + 49, + 51, + 50, + 44, + 50, + 49, + 51, + 44, + 49, + 54, + 57, + 44, + 51, + 44, + 57, + 48, + 44, + 53, + 56, + 44, + 49, + 54, + 50, + 44, + 57, + 56, + 44, + 50, + 52, + 54, + 44, + 49, + 54, + 52, + 44, + 53, + 48, + 44, + 49, + 49, + 56, + 44, + 49, + 51, + 49, + 44, + 50, + 52, + 57, + 44, + 50, + 48, + 53, + 44, + 50, + 49, + 54, + 44, + 49, + 49, + 57, + 44, + 49, + 51, + 49, + 44, + 49, + 55, + 49, + 44, + 49, + 51, + 48, + 44, + 49, + 54, + 51, + 93, + 125, + 125, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 83, + 105, + 103, + 110, + 105, + 110, + 103, + 75, + 101, + 121, + 34, + 58, + 123, + 34, + 112, + 117, + 98, + 108, + 105, + 99, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 53, + 44, + 50, + 49, + 48, + 44, + 53, + 44, + 49, + 52, + 52, + 44, + 49, + 49, + 48, + 44, + 50, + 53, + 44, + 49, + 48, + 56, + 44, + 49, + 51, + 44, + 49, + 57, + 56, + 44, + 50, + 52, + 57, + 44, + 50, + 49, + 48, + 44, + 49, + 55, + 52, + 44, + 50, + 50, + 50, + 44, + 48, + 44, + 50, + 52, + 55, + 44, + 51, + 49, + 44, + 49, + 55, + 49, + 44, + 49, + 54, + 57, + 44, + 49, + 56, + 55, + 44, + 49, + 50, + 56, + 44, + 49, + 53, + 49, + 44, + 49, + 57, + 50, + 44, + 57, + 50, + 44, + 49, + 51, + 54, + 44, + 50, + 48, + 49, + 44, + 52, + 53, + 44, + 49, + 55, + 50, + 44, + 54, + 49, + 44, + 49, + 52, + 52, + 44, + 50, + 49, + 54, + 44, + 53, + 54, + 44, + 50, + 53, + 44, + 49, + 51, + 93, + 125, + 44, + 34, + 112, + 114, + 105, + 118, + 97, + 116, + 101, + 34, + 58, + 123, + 34, + 116, + 121, + 112, + 101, + 34, + 58, + 34, + 66, + 117, + 102, + 102, + 101, + 114, + 34, + 44, + 34, + 100, + 97, + 116, + 97, + 34, + 58, + 91, + 93, + 125, + 125, + 44, + 34, + 115, + 101, + 110, + 100, + 101, + 114, + 77, + 101, + 115, + 115, + 97, + 103, + 101, + 75, + 101, + 121, + 115, + 34, + 58, + 91, + 93, + 125, + 93 + ] + } + } + } + }, + "dmTranscript": [ + { + "dir": "a2b", + "type": "pkmsg", + "ct": "MwgBEiEFFhwYwPexZfP4jMJdguPxDtdbk3K8Q3tarbjaIW9OPgAaIQWjQWHl07o21lCBDh5FfTHeMfoyo8kHx10vNegz7UuoCyJSMwohBWZkNHQDFNGwWjnHJZqCH5BVeSb0IRX9uBgiX8tZoo4GEAAYACIgnmVw9pxcyxH+h8rkNWyvSqxTzpJ2fpsXsHUvTVYA5yqRA41mUQeLZygcMAE=", + "pt": "msg-1 from alice" + }, + { + "dir": "b2a", + "type": "msg", + "ct": "MwohBZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhuEAAYACIQOz5hRleLiwIYzZmWgcFXrIDEDcBbrmoe", + "pt": "msg-2 from bob" + }, + { + "dir": "a2b", + "type": "msg", + "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAAYACIg+qpLVR8j70REQK1xwWG6boVJ6CFdSXJ3DRavbEfQ79lJ8LZtKUBY4g==", + "pt": "msg-3 from alice" + } + ], + "pending": [ + { + "type": "msg", + "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAEYACIQ962b1VLGDctn2YBY2kj0ic7Ai+jp8au0", + "pt": "pending-1" + }, + { + "type": "msg", + "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAIYACIQUGHRBuJrm5+n1xBvCfSYVqkNpxyOLveU", + "pt": "pending-2" + } + ], + "groupTranscript": [ + { + "ct": "MwjcsrfuBxAAGhBRrAkVr7SLgKCFdn8LHbJ9c8BJL9OfydIfLtISginFN60Gy6GmlSusVyNbu++IAamVcb1ir0FP9SOebpUo1ezV4xPPngT+rxeOdiO/4oImBQ==", + "pt": "group-1" + } + ], + "pendingGroup": [ + { + "ct": "MwjcsrfuBxACGhAvmgj8R+f17g81uFSMq1r/4tYe2VksPseAuEHiE2UC7CrClG8/SIKAK0l0gRF1hdvruRJdmR5GkZ8dsSS1r907wznMKPNYke8EcYNF21l2BQ==", + "pt": "group-pending-1" + }, + { + "ct": "MwjcsrfuBxAEGhCwWg6AUukRZ2V86u+JsrRnXWii6MCjILBkUUt0OBdhvxqK13Qw68wGS8CJ50GHNc6n0yk+HFhc2mnanLMwjStGqUrheK969OuZLDcEKTXOCQ==", + "pt": "group-pending-2" + } + ] +} \ No newline at end of file diff --git a/packages/baileys/src/__tests__/fixtures/rollback-step1.ts b/packages/baileys/src/__tests__/fixtures/rollback-step1.ts new file mode 100644 index 00000000000..b2d8501a88a --- /dev/null +++ b/packages/baileys/src/__tests__/fixtures/rollback-step1.ts @@ -0,0 +1,112 @@ +// Step 1 of the rollback proof, run with tsx inside packages/baileys. +// +// Bob upgrades to this branch carrying an rc.9 auth state: he consumes the +// ciphertexts the old build left pending, sends a new message, and then decides +// to go back — so his session is projected into the legacy JSON shape again. +// Step 2 feeds that projection to the real rc.9 and keeps the conversation going. +import { writeFileSync } from 'node:fs' +import P from 'pino' +import { projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' +import { fromTypedRecord } from '../../Signal/legacy-session-codec' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability } from '../../Utils/auth-utils' +import fixture from './legacy-session-rc9.json' + +const logger = P({ level: 'silent' }) + +const revive = (value: unknown): unknown => { + if (typeof value === 'object' && value !== null && (value as { type?: string }).type === 'Buffer') { + return Buffer.from((value as { data: string }).data, 'base64') + } + + if (Array.isArray(value)) return value.map(revive) + if (ArrayBuffer.isView(value)) return value + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)])) + } + + return value +} + +const data = revive(fixture.bob.store) as Record> +const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) { + out[id] = bucket[id] as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } +} + +const auth: SignalAuthState = { + creds: revive(fixture.bob.creds) as never, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) +} +const repository = makeLibSignalRepository(auth, logger) + +const { aliceJid, groupJid } = fixture.jids +const sessionAddr = '5511900000001.0' + +const main = async () => { + // 1. Consume what the old build enciphered but never delivered. + for (const message of fixture.pending) { + const plaintext = await repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: Buffer.from(message.ct, 'base64') + }) + if (Buffer.from(plaintext).toString() !== message.pt) throw new Error(`DM mismatch: ${message.pt}`) + } + + for (const message of fixture.pendingGroup) { + const plaintext = await repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: Buffer.from(message.ct, 'base64') + }) + if (Buffer.from(plaintext).toString() !== message.pt) throw new Error(`group mismatch: ${message.pt}`) + } + + // 2. Send something new from the upgraded build. + const outgoing = await repository.encryptMessage({ jid: aliceJid, data: Buffer.from('from-new-bob') }) + + // 3. Roll back: project the session Bob is now using into legacy JSON. + const stored = data.session![sessionAddr] as Uint8Array + const projection = projectLegacySessionRecordV1(stored) + if (projection.status !== 'projected') { + throw new Error(`session is not projectable: ${JSON.stringify(projection.issue)}`) + } + + writeFileSync( + process.argv[2]!, + JSON.stringify({ + outgoing: { type: outgoing.type, ct: Buffer.from(outgoing.ciphertext).toString('base64') }, + projectedSession: fromTypedRecord(projection.record), + senderKey: Buffer.from(data['sender-key']![`${groupJid}::5511900000001::0`] as Uint8Array).toString('base64') + }) + ) + + console.log('step1 ok: consumed pending DM + group, sent one message, projected session back') +} + +main().catch(error => { + console.error('step1 FAILED:', error) + process.exit(1) +}) diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index ab76dff3874..fde5b7f5dc2 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" dependencies = [ "cipher", "cpubits", @@ -25,27 +25,32 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -99,6 +104,62 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "buffa" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf9e6224bc4ee1f189ad257120c156fb05f95b826f5369d620b24984476c200a" +dependencies = [ + "base64", + "bytes", + "foldhash", + "hashbrown 0.15.5", + "once_cell", + "rustversion", + "serde", + "serde_json", + "smoothutf8", + "thiserror", +] + +[[package]] +name = "buffa-build" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "247d43740a4854a9c1b98a5931d6d67d8f8b41ffeb2a43ca008d460e79b1eb2c" +dependencies = [ + "buffa", + "buffa-codegen", + "tempfile", +] + +[[package]] +name = "buffa-codegen" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2074a9c2f76b2ebe6af40f7bf67b6a19d6ce3663253ef2a55124dc93f38b230e" +dependencies = [ + "buffa", + "buffa-descriptor", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "thiserror", +] + +[[package]] +name = "buffa-descriptor" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964d735e0b06cb24fa15a68c5aa99549abcc7e0bbeb76298f2fec57912fb79c1" +dependencies = [ + "buffa", + "rustversion", + "serde", + "serde_json", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -119,9 +180,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "castaway" @@ -155,7 +216,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -186,17 +247,16 @@ checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" [[package]] name = "compact_str" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" dependencies = [ "castaway", "cfg-if", "itoa", - "rustversion", - "ryu", "serde", "static_assertions", + "zmij", ] [[package]] @@ -279,42 +339,36 @@ dependencies = [ "cpufeatures 0.2.17", "curve25519-dalek-derive", "digest 0.10.7", - "fiat-crypto", + "fiat-crypto 0.2.9", "rustc_version", "subtle", "zeroize", ] [[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_more" -version = "2.1.1" +name = "curve25519-dalek" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" dependencies = [ - "derive_more-impl", + "cfg-if", + "cpufeatures 0.3.0", + "curve25519-dalek-derive", + "fiat-crypto 0.3.0", + "rustc_version", + "subtle", + "zeroize", ] [[package]] -name = "derive_more-impl" -version = "2.1.1" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "rustc_version", - "syn", + "syn 2.0.117", ] [[package]] @@ -340,36 +394,54 @@ dependencies = [ ] [[package]] -name = "displaydoc" -version = "0.2.5" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "proc-macro2", - "quote", - "syn", + "cfg-if", ] [[package]] -name = "either" -version = "1.15.0" +name = "equivalent" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "cfg-if", + "libc", + "windows-sys", ] [[package]] -name = "equivalent" -version = "1.0.2" +name = "event-listener" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -386,6 +458,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + [[package]] name = "flate2" version = "1.1.9" @@ -394,7 +472,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -447,7 +524,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -469,6 +546,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", + "serde", ] [[package]] @@ -486,7 +564,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -611,15 +689,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" @@ -656,6 +725,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -743,6 +818,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + [[package]] name = "prettyplease" version = "0.2.37" @@ -750,7 +831,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -769,20 +850,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", ] [[package]] @@ -820,15 +887,9 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom", - "rand_core 0.10.1", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.10.1" @@ -845,16 +906,23 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] [[package]] -name = "ryu" -version = "1.0.23" +name = "rustversion" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "semver" @@ -919,7 +987,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -930,7 +998,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -985,12 +1053,36 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "smoothutf8" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b4ec95892483d6d94284caccfddb9b77111a4dcac33ed25d624248def0fa5f1" +dependencies = [ + "simdutf8", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1118,6 +1210,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1126,27 +1229,40 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -1170,7 +1286,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -1220,15 +1336,15 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore-appstate" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" dependencies = [ "anyhow", - "bytemuck", + "buffa", "hex", "hkdf 0.13.0", + "hmac 0.13.0", "log", - "prost", "serde", "serde-big-array", "serde_json", @@ -1241,62 +1357,72 @@ dependencies = [ [[package]] name = "wacore-binary" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" dependencies = [ "bytes", "compact_str", - "flate2", - "hashify", "itoa", "serde", "serde_json", + "smallvec", + "smoothutf8", "stable_deref_trait", "yoke", + "zlib-rs", +] + +[[package]] +name = "wacore-derive" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] name = "wacore-libsignal" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" dependencies = [ "aes", - "arrayref", + "async-lock", "async-trait", + "buffa", "bytes", "cbc", "chrono", "ctr", - "curve25519-dalek", - "derive_more", - "displaydoc", + "curve25519-dalek 5.0.0", "ghash", "hex", "hkdf 0.13.0", "hmac 0.13.0", "log", - "prost", + "portable-atomic", "rand", "serde", "sha1", "sha2 0.11.0", "subtle", "thiserror", - "uuid", + "wacore-derive", "waproto", "x25519-dalek", ] [[package]] name = "wacore-noise" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" dependencies = [ "anyhow", + "buffa", "bytes", "hkdf 0.13.0", "log", - "prost", "rand", "sha2 0.11.0", "thiserror", @@ -1307,11 +1433,16 @@ dependencies = [ [[package]] name = "waproto" -version = "0.5.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#88a0fe074fdc09d73be95cdd23743fba22ef172e" +version = "0.6.0" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" dependencies = [ - "prost", + "buffa", + "buffa-build", + "buffa-descriptor", + "bytes", + "heck", "serde", + "sha2 0.11.0", ] [[package]] @@ -1374,7 +1505,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1437,7 +1568,9 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", - "curve25519-dalek", + "buffa", + "bytes", + "curve25519-dalek 4.1.3", "getrandom", "hashify", "hkdf 0.12.4", @@ -1468,6 +1601,21 @@ dependencies = [ "web-sys", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1504,7 +1652,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1520,7 +1668,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1564,13 +1712,12 @@ dependencies = [ [[package]] name = "x25519-dalek" -version = "2.0.1" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", - "rand_core 0.6.4", - "serde", + "curve25519-dalek 5.0.0", + "rand_core", "zeroize", ] @@ -1593,7 +1740,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1614,7 +1761,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1623,26 +1770,12 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/packages/whatsapp-rust-bridge/Cargo.toml b/packages/whatsapp-rust-bridge/Cargo.toml index 6dca4ad27f8..d7ffd929c2a 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -44,63 +44,67 @@ sticker = ["dep:img-parts"] [dependencies] async-trait = "0.1.89" base64 = { version = "0.22.1", default-features = false, features = ["alloc"] } + +buffa = { version = "0.9.1", default-features = false } +bytes = { version = "1.11", default-features = false } curve25519-dalek = { version = "4.1.3", default-features = false, features = [ - "alloc", - "digest", - "precomputed-tables", + "alloc", + "digest", + "precomputed-tables", ] } getrandom = { version = "0.4", features = ["wasm_js"] } hashify = { version = "0.2.9", default-features = false, features = ["force-32bit"] } hkdf = "0.12" hmac = "0.12" image = { version = "0.25.5", default-features = false, features = [ - "jpeg", - "png", - "webp", + "jpeg", + "png", + "webp", ], optional = true } - img-parts = { version = "0.4", default-features = false, optional = true } js-sys = "0.3" log = "0.4" md-5 = "0.10" prost = { version = "0.14.1", default-features = false } rand = { version = "0.10", default-features = false, features = [ - "std", - "std_rng", - "sys_rng", + "std", + "std_rng", + "sys_rng", ] } serde = { version = "1.0.228", default-features = false, features = [ - "derive", - "alloc", + "derive", + "alloc", ] } serde-wasm-bindgen = "0.6.5" serde_bytes = "0.11" serde_json = { version = "1.0", default-features = false, features = ["alloc"] } sha2 = "0.10" simd-adler32 = { version = "0.3.7", default-features = false, features = [ - "std", + "std", ] } symphonia = { version = "0.5.4", default-features = false, features = [ - "mp3", - "aac", - "isomp4", - "ogg", + "mp3", + "aac", + "isomp4", + "ogg", ], optional = true } tsify = { version = "0.5.6", default-features = false, features = ["js"] } uuid = { version = "1.18.1", default-features = false, features = ["v4", "js"] } wacore-appstate = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "wacore-appstate" } wacore-binary = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", default-features = false, features = [ - "serde", + "serde", +] } +wacore-libsignal = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "wacore-libsignal", features = [ + "legacy-session-interop", ] } -wacore-libsignal = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "wacore-libsignal" } wacore-noise = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "wacore-noise" } waproto = { git = "https://github.com/jlucaso1/whatsapp-rust.git", branch = "main", package = "waproto" } wasm-bindgen = "0.2.100" wasm-bindgen-futures = "0.4.55" web-sys = { version = "0.3", features = [ - "ReadableStream", - "ReadableStreamDefaultReader", + "ReadableStream", + "ReadableStreamDefaultReader", ] } [profile.release] diff --git a/packages/whatsapp-rust-bridge/src/legacy_session.rs b/packages/whatsapp-rust-bridge/src/legacy_session.rs new file mode 100644 index 00000000000..f8bc72e5073 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/legacy_session.rs @@ -0,0 +1,482 @@ +//! Typed boundary for the decoded libsignal `SessionRecord` v1 model. + +use bytes::Bytes; +use js_sys::Uint8Array; +use serde::{Deserialize, Serialize}; +use tsify::Tsify; +use wasm_bindgen::prelude::*; +use wacore_libsignal::protocol::{ + IdentityKey, LegacyIndexedSessionV1 as CoreIndexedSession, + LegacySessionBaseKeyRoleV1 as CoreBaseKeyRole, LegacySessionChainCounterV1 as CoreChainCounter, + LegacySessionChainKeyV1 as CoreChainKey, LegacySessionChainRoleV1 as CoreChainRole, + LegacySessionChainV1 as CoreChain, LegacySessionDispositionV1 as CoreDisposition, + LegacySessionInteropError as CoreInteropError, LegacySessionKeyPairV1 as CoreKeyPair, + LegacySessionLocalContext as CoreLocalContext, LegacySessionMessageKeyV1 as CoreMessageKey, + LegacySessionPendingPreKeyV1 as CorePendingPreKey, LegacySessionRatchetV1 as CoreRatchet, + LegacySessionRecordV1 as CoreRecord, + LegacySessionUnrepresentableFieldV1 as CoreUnrepresentableField, + LegacySessionV1 as CoreSession, SessionRecord, +}; + +fn byte_array(bytes: &[u8]) -> Uint8Array { + Uint8Array::from(bytes) +} + +#[derive(Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionRecordV1 { + pub sessions: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacyIndexedSessionV1 { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub index_key: Vec, + pub session: LegacySessionV1, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionV1 { + pub registration_id: u32, + pub ratchet: LegacySessionRatchetV1, + pub index: LegacySessionIndexV1, + pub chains: Vec, + pub pending_pre_key: Option, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionRatchetV1 { + pub key_pair: LegacySessionKeyPairV1, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub last_remote_ephemeral_key: Vec, + /// v1 allows `-1` for a never-used sending chain; the core validates the + /// `-1..=u32::MAX` range and owns the floor-to-zero translation. + pub previous_counter: i64, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub root_key: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionKeyPairV1 { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub public: Vec, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub private: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionIndexV1 { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub base_key: Vec, + pub base_key_role: u32, + pub closed_timestamp: i64, + pub used_at_ms: u64, + pub created_at_ms: u64, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub remote_identity_key: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionChainV1 { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub ratchet_key: Vec, + pub role: u32, + pub chain_key: LegacySessionChainKeyV1, + pub message_keys: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionChainKeyV1 { + pub counter: i64, + #[tsify(type = "Uint8Array | undefined")] + #[serde(default, skip_serializing_if = "Option::is_none", with = "serde_bytes")] + pub key: Option>, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionMessageKeyV1 { + pub index: u32, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub seed: Vec, +} + +#[derive(Serialize, Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionPendingPreKeyV1 { + pub pre_key_id: Option, + pub signed_pre_key_id: u32, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub base_key: Vec, +} + +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionLocalContext { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub identity_key: Vec, + pub registration_id: u32, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde( + tag = "status", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum LegacySessionProjectionV1 { + Projected { + record: LegacySessionRecordV1, + }, + Unrepresentable { + issue: LegacySessionProjectionIssueV1, + }, +} + +#[derive(Serialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct LegacySessionProjectionIssueV1 { + pub session: usize, + pub chain: Option, + pub field: LegacySessionUnrepresentableFieldV1, +} + +#[derive(Serialize, Tsify)] +#[serde(rename_all = "snake_case")] +pub enum LegacySessionUnrepresentableFieldV1 { + SessionVersion, + SenderChain, + PendingKeyExchange, + PostQuantumPreKey, + RefreshState, + DerivedMessageKey, + LastRemoteEphemeralKey, +} + +impl TryFrom for CoreRecord { + type Error = CoreInteropError; + + fn try_from(value: LegacySessionRecordV1) -> Result { + let sessions = value + .sessions + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?; + Self::from_indexed_sessions(sessions) + } +} + +impl From for LegacySessionRecordV1 { + fn from(value: CoreRecord) -> Self { + Self { + sessions: value + .into_indexed_sessions() + .into_iter() + .map(Into::into) + .collect(), + } + } +} + +impl TryFrom for CoreIndexedSession { + type Error = CoreInteropError; + + fn try_from(value: LegacyIndexedSessionV1) -> Result { + Ok(Self { + index_key: Bytes::from(value.index_key), + session: value.session.try_into()?, + }) + } +} + +impl From for LegacyIndexedSessionV1 { + fn from(value: CoreIndexedSession) -> Self { + Self { + index_key: value.index_key.into(), + session: value.session.into(), + } + } +} + +impl TryFrom for CoreSession { + type Error = CoreInteropError; + + fn try_from(value: LegacySessionV1) -> Result { + Ok(Self { + registration_id: value.registration_id, + ratchet: value.ratchet.into(), + index: value.index.try_into()?, + chains: value + .chains + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?, + pending_pre_key: value.pending_pre_key.map(Into::into), + }) + } +} + +impl From for LegacySessionV1 { + fn from(value: CoreSession) -> Self { + Self { + registration_id: value.registration_id, + ratchet: value.ratchet.into(), + index: value.index.into(), + chains: value.chains.into_iter().map(Into::into).collect(), + pending_pre_key: value.pending_pre_key.map(Into::into), + } + } +} + +impl From for CoreRatchet { + fn from(value: LegacySessionRatchetV1) -> Self { + Self { + key_pair: value.key_pair.into(), + last_remote_ephemeral_key: value.last_remote_ephemeral_key.into(), + previous_counter: value.previous_counter, + root_key: value.root_key.into(), + } + } +} + +impl From for LegacySessionRatchetV1 { + fn from(value: CoreRatchet) -> Self { + Self { + key_pair: value.key_pair.into(), + last_remote_ephemeral_key: value.last_remote_ephemeral_key.into(), + previous_counter: value.previous_counter, + root_key: value.root_key.into(), + } + } +} + +impl From for CoreKeyPair { + fn from(value: LegacySessionKeyPairV1) -> Self { + Self { + public: value.public.into(), + private: value.private.into(), + } + } +} + +impl From for LegacySessionKeyPairV1 { + fn from(value: CoreKeyPair) -> Self { + Self { + public: value.public.into(), + private: value.private.into(), + } + } +} + +impl TryFrom + for wacore_libsignal::protocol::LegacySessionIndexV1 +{ + type Error = CoreInteropError; + + fn try_from(value: LegacySessionIndexV1) -> Result { + Ok(Self { + base_key: value.base_key.into(), + base_key_role: CoreBaseKeyRole::try_from(value.base_key_role)?, + disposition: CoreDisposition::from_closed_timestamp(value.closed_timestamp)?, + used_at_ms: value.used_at_ms, + created_at_ms: value.created_at_ms, + remote_identity_key: value.remote_identity_key.into(), + }) + } +} + +impl From + for LegacySessionIndexV1 +{ + fn from(value: wacore_libsignal::protocol::LegacySessionIndexV1) -> Self { + Self { + base_key: value.base_key.into(), + base_key_role: u32::try_from(value.base_key_role.code()) + .expect("legacy base-key role wire code is non-negative"), + closed_timestamp: value.disposition.closed_timestamp(), + used_at_ms: value.used_at_ms, + created_at_ms: value.created_at_ms, + remote_identity_key: value.remote_identity_key.into(), + } + } +} + +impl TryFrom for CoreChain { + type Error = CoreInteropError; + + fn try_from(value: LegacySessionChainV1) -> Result { + Ok(Self { + ratchet_key: value.ratchet_key.into(), + role: CoreChainRole::try_from(value.role)?, + chain_key: value.chain_key.try_into()?, + message_keys: value.message_keys.into_iter().map(Into::into).collect(), + }) + } +} + +impl From for LegacySessionChainV1 { + fn from(value: CoreChain) -> Self { + Self { + ratchet_key: value.ratchet_key.into(), + role: u32::try_from(value.role.code()) + .expect("legacy chain role wire code is non-negative"), + chain_key: value.chain_key.into(), + message_keys: value.message_keys.into_iter().map(Into::into).collect(), + } + } +} + +impl TryFrom for CoreChainKey { + type Error = CoreInteropError; + + fn try_from(value: LegacySessionChainKeyV1) -> Result { + Ok(Self { + counter: CoreChainCounter::new(value.counter)?, + key: value.key.map(Into::into), + }) + } +} + +impl From for LegacySessionChainKeyV1 { + fn from(value: CoreChainKey) -> Self { + Self { + counter: value.counter.value(), + key: value.key.map(Into::into), + } + } +} + +impl From for CoreMessageKey { + fn from(value: LegacySessionMessageKeyV1) -> Self { + Self { + index: value.index, + seed: value.seed.into(), + } + } +} + +impl From for LegacySessionMessageKeyV1 { + fn from(value: CoreMessageKey) -> Self { + Self { + index: value.index, + seed: value.seed.into(), + } + } +} + +impl From for CorePendingPreKey { + fn from(value: LegacySessionPendingPreKeyV1) -> Self { + Self { + pre_key_id: value.pre_key_id, + signed_pre_key_id: value.signed_pre_key_id, + base_key: value.base_key.into(), + } + } +} + +impl From for LegacySessionPendingPreKeyV1 { + fn from(value: CorePendingPreKey) -> Self { + Self { + pre_key_id: value.pre_key_id, + signed_pre_key_id: value.signed_pre_key_id, + base_key: value.base_key.into(), + } + } +} + +impl From for LegacySessionUnrepresentableFieldV1 { + fn from(value: CoreUnrepresentableField) -> Self { + match value { + CoreUnrepresentableField::SessionVersion => Self::SessionVersion, + CoreUnrepresentableField::SenderChain => Self::SenderChain, + CoreUnrepresentableField::PendingKeyExchange => Self::PendingKeyExchange, + CoreUnrepresentableField::PostQuantumPreKey => Self::PostQuantumPreKey, + CoreUnrepresentableField::RefreshState => Self::RefreshState, + CoreUnrepresentableField::DerivedMessageKey => Self::DerivedMessageKey, + CoreUnrepresentableField::LastRemoteEphemeralKey => Self::LastRemoteEphemeralKey, + } + } +} + +fn projection_issue( + error: CoreInteropError, +) -> Result { + match error { + CoreInteropError::NotRepresentable { session, field } => { + Ok(LegacySessionProjectionIssueV1 { + session, + chain: None, + field: field.into(), + }) + } + CoreInteropError::ChainNotRepresentable { + session, + chain, + field, + } => Ok(LegacySessionProjectionIssueV1 { + session, + chain: Some(chain), + field: field.into(), + }), + error => Err(error), + } +} + +#[wasm_bindgen(js_name = importLegacySessionRecordV1)] +pub fn import_legacy_session_record_v1( + record: LegacySessionRecordV1, + context: LegacySessionLocalContext, +) -> Result { + let identity_key = IdentityKey::decode(&context.identity_key) + .map_err(|error| JsValue::from_str(&format!("{}: {}", "context.identityKey", error.to_string())))?; + let record = CoreRecord::try_from(record) + .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; + let record = record + .into_session_record(CoreLocalContext { + identity_key, + registration_id: context.registration_id, + }) + .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; + let bytes = record.serialize().map_err(|error| { + JsValue::from_str(&format!("serialize imported legacy session record: {error}")) + })?; + Ok(byte_array(&bytes)) +} + +#[wasm_bindgen(js_name = projectLegacySessionRecordV1)] +pub fn project_legacy_session_record_v1( + bytes: &[u8], +) -> Result { + let record = SessionRecord::deserialize(bytes) + .map_err(|error| JsValue::from_str(&format!("{}: {}", "recordBytes", error.to_string())))?; + match record.into_legacy_session_v1_operational() { + Ok(record) => Ok(LegacySessionProjectionV1::Projected { + record: record.into(), + }), + Err(error) => match projection_issue(error) { + Ok(issue) => Ok(LegacySessionProjectionV1::Unrepresentable { issue }), + Err(error) => Err(JsValue::from_str(&format!("{}: {}", "recordBytes", error.to_string()))), + }, + } +} diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index de0f435f55d..cd878cf4d2d 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -9,6 +9,7 @@ pub mod group_types; #[cfg(feature = "image")] pub mod image_utils; pub mod key_helper; +pub mod legacy_session; pub mod logger; pub mod noise_session; pub mod protocol_address; diff --git a/packages/whatsapp-rust-bridge/src/protocol_address.rs b/packages/whatsapp-rust-bridge/src/protocol_address.rs index 5a3577eef49..08dd6e94950 100644 --- a/packages/whatsapp-rust-bridge/src/protocol_address.rs +++ b/packages/whatsapp-rust-bridge/src/protocol_address.rs @@ -32,7 +32,7 @@ impl ProtocolAddress { } Ok(ProtocolAddress(CoreProtocolAddress::new( - id_str, + &id_str, DeviceId::from(device_id_num), ))) } @@ -56,7 +56,7 @@ impl ProtocolAddress { .map_err(|_| JsValue::from_str(INVALID_ENCODING))?; Ok(ProtocolAddress(CoreProtocolAddress::new( - id_str.to_string(), + id_str, DeviceId::from(device_id_num), ))) } diff --git a/packages/whatsapp-rust-bridge/src/session_cipher.rs b/packages/whatsapp-rust-bridge/src/session_cipher.rs index 2e49ac0e165..522b0012236 100644 --- a/packages/whatsapp-rust-bridge/src/session_cipher.rs +++ b/packages/whatsapp-rust-bridge/src/session_cipher.rs @@ -99,7 +99,7 @@ impl SessionCipher { JsValue::from_str(&msg) })?; - Ok(bytes_to_uint8array(&plaintext)) + Ok(bytes_to_uint8array(&plaintext.plaintext)) } #[wasm_bindgen(js_name = decryptWhisperMessage)] @@ -131,7 +131,7 @@ impl SessionCipher { JsValue::from_str(&msg) })?; - Ok(bytes_to_uint8array(&plaintext)) + Ok(bytes_to_uint8array(&plaintext.plaintext)) } #[wasm_bindgen(js_name = hasOpenSession)] diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 6136b8108a7..2b57becf198 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1,13 +1,13 @@ use async_trait::async_trait; use base64::prelude::*; use js_sys::{Promise, Uint8Array}; -use prost::Message; use serde::Deserialize; use serde::de::DeserializeOwned; use serde_bytes::ByteBuf; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; +use buffa::{Message as _, MessageField}; use waproto::whatsapp::{ RecordStructure, SenderKeyRecordStructure, SenderKeyStateStructure, SessionStructure, sender_key_state_structure::{SenderChainKey, SenderMessageKey, SenderSigningKey}, @@ -384,7 +384,7 @@ impl JsStorageAdapter { } } - let mut sender_chain_struct = None; + let mut sender_chain_struct = MessageField::none(); if let Some((pub_key, priv_key, chain_key, counter, msg_keys)) = sender_chain { let mut message_keys_vec = Vec::new(); @@ -397,10 +397,10 @@ impl JsStorageAdapter { }); } - sender_chain_struct = Some(Chain { + sender_chain_struct = MessageField::some(Chain { sender_ratchet_key: Some(pub_key), sender_ratchet_key_private: Some(priv_key), - chain_key: Some(ChainKey { + chain_key: MessageField::some(ChainKey { index: Some(counter), key: Some(chain_key.into()), }), @@ -423,7 +423,7 @@ impl JsStorageAdapter { receiver_chains_vec.push(Chain { sender_ratchet_key: Some(sender_ratchet), sender_ratchet_key_private: None, - chain_key: Some(ChainKey { + chain_key: MessageField::some(ChainKey { index: Some(counter), key: Some(chain_key.into()), }), @@ -441,8 +441,8 @@ impl JsStorageAdapter { previous_counter: Some(previous_counter), sender_chain: sender_chain_struct, receiver_chains: receiver_chains_vec, - pending_key_exchange: None, - pending_pre_key: None, + pending_key_exchange: MessageField::none(), + pending_pre_key: MessageField::none(), remote_registration_id: Some(registration_id), local_registration_id: Some(local_reg_id), needs_refresh: None, @@ -450,7 +450,7 @@ impl JsStorageAdapter { }; let record = RecordStructure { - current_session: Some(session), + current_session: MessageField::some(session), previous_sessions: Vec::new(), }; @@ -522,8 +522,8 @@ impl JsStorageAdapter { sender_key_states.push(SenderKeyStateStructure { sender_key_id: Some(sender_key_id), - sender_chain_key: Some(chain_key), - sender_signing_key: Some(signing_key), + sender_chain_key: MessageField::some(chain_key), + sender_signing_key: MessageField::some(signing_key), sender_message_keys, }); } From 45922f6de1787efc2644a8e39a42b3443f30b44f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:09:18 -0300 Subject: [PATCH 24/71] fix(bridge): key identity records by the full address save_identity, is_trusted_identity and get_identity used address.name(), which drops the device, while sessions use the full address. The transaction layer then locked a different id than the record being written, so an encrypt and a decrypt on one session could run at the same time and rewind the sending chain. The peer rejects the next ciphertext as "message with old counter". --- packages/whatsapp-rust-bridge/src/storage_adapter.rs | 11 ++++++++--- .../whatsapp-rust-bridge/test/session_builder.test.ts | 8 ++++---- .../whatsapp-rust-bridge/test/session_cipher.test.ts | 8 ++++---- .../whatsapp-rust-bridge/test/storage_adapter.test.ts | 5 +++-- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 2b57becf198..be137714ac3 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -928,7 +928,7 @@ impl IdentityKeyStore for JsStorageAdapter { identity: &libsignal::IdentityKey, direction: StoreDirection, ) -> SignalResult { - let address_name = address.name().to_string(); + let address_name = self.get_address_string(address); let identity_bytes = identity.serialize(); if let Some(cached_key) = self.cached_identities.borrow().get(&address_name) @@ -962,7 +962,12 @@ impl IdentityKeyStore for JsStorageAdapter { address: &libsignal::ProtocolAddress, identity: &libsignal::IdentityKey, ) -> SignalResult { - let address_name = address.name().to_string(); + // Identity records are keyed by the SAME address as the session they + // belong to. Using `name()` here drops the device id, which both keys + // the record differently from `load_session`/`store_session` AND makes + // the transaction layer lock a different id — so an identity write no + // longer serializes against the encrypt/decrypt touching that session. + let address_name = self.get_address_string(address); let identity_bytes = identity.serialize(); let previous_identity = self.load_peer_identity(&address_name).await?; @@ -992,7 +997,7 @@ impl IdentityKeyStore for JsStorageAdapter { &self, address: &libsignal::ProtocolAddress, ) -> SignalResult> { - let address_name = address.name().to_string(); + let address_name = self.get_address_string(address); self.load_peer_identity(&address_name) .await? .map(|identity| libsignal::IdentityKey::decode(&identity)) diff --git a/packages/whatsapp-rust-bridge/test/session_builder.test.ts b/packages/whatsapp-rust-bridge/test/session_builder.test.ts index f0bd571f098..67be9e7cff0 100644 --- a/packages/whatsapp-rust-bridge/test/session_builder.test.ts +++ b/packages/whatsapp-rust-bridge/test/session_builder.test.ts @@ -82,7 +82,7 @@ describe("SessionBuilder", () => { 0 ); expect(isTrusted).toBe(true); - expect(aliceStorage.getIdentity("bob")).toEqual(bobIdentityKeyPair.pubKey); + expect(aliceStorage.getIdentity("bob.1")).toEqual(bobIdentityKeyPair.pubKey); expect(aliceStorage.identityLoadCount).toBeGreaterThan(0); expect(aliceStorage.identitySaveCount).toBeGreaterThan(0); }); @@ -95,7 +95,7 @@ describe("SessionBuilder", () => { await new SessionBuilder(storage, bobAddress).processPreKeyBundle( first.bundle ); - expect(storage.getIdentity("bob-persisted")).toEqual( + expect(storage.getIdentity("bob-persisted.1")).toEqual( first.identityKeyPair.pubKey ); @@ -104,7 +104,7 @@ describe("SessionBuilder", () => { second.bundle ); - expect(storage.getIdentity("bob-persisted")).toEqual( + expect(storage.getIdentity("bob-persisted.1")).toEqual( second.identityKeyPair.pubKey ); expect(storage.identityLoadCount).toBeGreaterThanOrEqual(2); @@ -120,7 +120,7 @@ describe("SessionBuilder", () => { const bobSignedPreKey = generateSignedPreKey(bobIdentityKeyPair, 1); const fakeIdentity = generateIdentityKeyPair(); - aliceStorage.trustIdentity("bob", fakeIdentity.pubKey); + aliceStorage.trustIdentity("bob.1", fakeIdentity.pubKey); const bobBundle = { registrationId: 1234, diff --git a/packages/whatsapp-rust-bridge/test/session_cipher.test.ts b/packages/whatsapp-rust-bridge/test/session_cipher.test.ts index 947da6373ee..214356b1b59 100644 --- a/packages/whatsapp-rust-bridge/test/session_cipher.test.ts +++ b/packages/whatsapp-rust-bridge/test/session_cipher.test.ts @@ -21,8 +21,8 @@ describe("SessionCipher end-to-end", () => { // Alice needs to trust Bob's identity key, and vice versa. // This simulates fetching the key from a server and verifying it. - aliceStorage.trustIdentity("bob", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", aliceStorage.ourIdentityKeyPair.pubKey); + aliceStorage.trustIdentity("bob.1", bobStorage.ourIdentityKeyPair.pubKey); + bobStorage.trustIdentity("alice.1", aliceStorage.ourIdentityKeyPair.pubKey); // === 2. BOB'S PRE-KEY BUNDLE === // Bob generates his keys and "uploads" them to the server (i.e., we store them in his storage). @@ -98,8 +98,8 @@ describe("SessionCipher end-to-end", () => { const aliceAddress = new ProtocolAddress("alice", 1); const bobAddress = new ProtocolAddress("bob", 1); - aliceStorage.trustIdentity("bob", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", aliceStorage.ourIdentityKeyPair.pubKey); + aliceStorage.trustIdentity("bob.1", bobStorage.ourIdentityKeyPair.pubKey); + bobStorage.trustIdentity("alice.1", aliceStorage.ourIdentityKeyPair.pubKey); const bobSignedPreKeyId = 7; const bobSignedPreKey = generateSignedPreKey( diff --git a/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts b/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts index 7262e8ef295..713b0929378 100644 --- a/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts +++ b/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts @@ -213,13 +213,14 @@ describe("StorageAdapter Interop", () => { expect.stringContaining("identity persistence failed") ); expect(storage.identityLoadCount).toBe(1); - expect(storage.getIdentity("alice")).toBeUndefined(); + // Identity is keyed by the full address, same as the session it belongs to. + expect(storage.getIdentity("alice.1")).toBeUndefined(); storage.failIdentityStore = false; storage.failSessionStore = false; await builder.processPreKeyBundle(bundle); expect(storage.identityLoadCount).toBe(2); - expect(storage.getIdentity("alice")).toEqual(bundle.identityKey); + expect(storage.getIdentity("alice.1")).toEqual(bundle.identityKey); }); }); From cb420b947ac3d246b32568c55c8f09d759e3d188 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:09:18 -0300 Subject: [PATCH 25/71] fix(bridge): delete the pre-key a prekey message consumed The core reports the consumed id instead of removing the record, so this path left a spent one-time pre-key in storage where it could be handed out again. --- .../whatsapp-rust-bridge/src/session_cipher.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/whatsapp-rust-bridge/src/session_cipher.rs b/packages/whatsapp-rust-bridge/src/session_cipher.rs index 522b0012236..b4cd13246f0 100644 --- a/packages/whatsapp-rust-bridge/src/session_cipher.rs +++ b/packages/whatsapp-rust-bridge/src/session_cipher.rs @@ -7,7 +7,7 @@ use crate::{ protocol_address::ProtocolAddress, storage_adapter::{JsStorageAdapter, SignalStorage}, }; -use wacore_libsignal::protocol::{self as libsignal, SessionStore, UsePQRatchet}; +use wacore_libsignal::protocol::{self as libsignal, PreKeyStore, SessionStore, UsePQRatchet}; #[inline] fn bytes_to_uint8array(bytes: &[u8]) -> Uint8Array { @@ -99,6 +99,20 @@ impl SessionCipher { JsValue::from_str(&msg) })?; + // The core reports the one-time key it consumed rather than deleting it, + // so this path has to do the delete itself — otherwise a spent pre-key + // stays in storage and can be handed out again. + if let Some(id) = plaintext.consumed_prekey_id { + PreKeyStore::remove_pre_key(&mut prekey_store, id) + .await + .map_err(|e| { + JsValue::from_str(&format!( + "SessionCipher.decryptPreKeyWhisperMessage failed to remove pre-key: {:?}", + e + )) + })?; + } + Ok(bytes_to_uint8array(&plaintext.plaintext)) } From 728f8b9161809603905b00eb4a192f813d3700b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:09:29 -0300 Subject: [PATCH 26/71] feat(bridge): run signal operations over a snapshot The callback store calls back into JS several times per operation. That forces the caller to predict which records an operation will touch so it can lock them up front, and it lets a nested scope take those records in an order that inverts against another scope's. Here the caller passes in everything the operation can need, the protocol runs in memory, and the mutations come back as an explicit changeset: one read, one write, no callbacks in between. Effects the core reports rather than applies, like the consumed pre-key, are part of that changeset. --- packages/whatsapp-rust-bridge/src/lib.rs | 2 + .../whatsapp-rust-bridge/src/snapshot_api.rs | 400 ++++++++++++++++++ .../src/snapshot_store.rs | 255 +++++++++++ .../test/snapshot_api.test.ts | 281 ++++++++++++ 4 files changed, 938 insertions(+) create mode 100644 packages/whatsapp-rust-bridge/src/snapshot_api.rs create mode 100644 packages/whatsapp-rust-bridge/src/snapshot_store.rs create mode 100644 packages/whatsapp-rust-bridge/test/snapshot_api.test.ts diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index cd878cf4d2d..a4d1e9d50dc 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -15,6 +15,8 @@ pub mod noise_session; pub mod protocol_address; pub mod sender_key_name; pub mod session_builder; +pub mod snapshot_api; +pub mod snapshot_store; pub mod session_cipher; pub mod session_record; #[cfg(feature = "sticker")] diff --git a/packages/whatsapp-rust-bridge/src/snapshot_api.rs b/packages/whatsapp-rust-bridge/src/snapshot_api.rs new file mode 100644 index 00000000000..446a3be59d4 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/snapshot_api.rs @@ -0,0 +1,400 @@ +//! Signal operations as pure functions: snapshot in, changeset out. +//! +//! The caller locks the peer address, loads everything the operation can need, +//! calls one of these, and writes the returned changes back. Nothing here calls +//! into JS, so an operation cannot re-enter the caller's transaction, cannot +//! acquire locks in a conflicting order, and cannot touch a record the caller +//! did not hand it. +//! +//! Effects the core reports (a consumed pre-key, a replaced identity) travel in +//! the changeset instead of being applied behind the caller's back, which is +//! what lets the whole operation land as one durable write. + +use rand::rngs::StdRng; +use serde::{Deserialize, Serialize}; +use tsify::Tsify; +use wasm_bindgen::prelude::*; + +use wacore_libsignal::protocol::{ + GenericSignedPreKey as _, PreKeyBundle, PublicKey as CorePublicKey, process_prekey_bundle, IdentityKey, IdentityKeyPair, KeyPair, PreKeyId, PreKeyRecord, + PreKeySignalMessage, PrivateKey, SenderKeyRecord, SessionRecord, SignalMessage, SignedPreKeyId, + SignedPreKeyRecord, Timestamp, UsePQRatchet, message_decrypt_prekey, message_decrypt_signal, + message_encrypt, +}; + +use crate::protocol_address::ProtocolAddress; +use crate::snapshot_store::{SnapshotChanges, SnapshotStore}; + +fn err(context: &str, detail: impl std::fmt::Display) -> JsValue { + JsValue::from_str(&format!("{context}: {detail}")) +} + +/// Accept either the bare 32-byte curve key or the 0x05-prefixed form. +fn with_prefix(bytes: Vec) -> Vec { + if bytes.len() == 32 { + let mut out = Vec::with_capacity(33); + out.push(0x05); + out.extend_from_slice(&bytes); + return out; + } + + bytes +} + +#[derive(Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotKeyPair { + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub public: Vec, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub private: Vec, +} + +#[derive(Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotPreKey { + pub id: u32, + pub key_pair: SnapshotKeyPair, +} + +#[derive(Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotSignedPreKey { + pub id: u32, + pub key_pair: SnapshotKeyPair, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub signature: Vec, +} + +/// Everything an operation may read. Anything absent does not exist as far as +/// the operation is concerned. +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct SignalSnapshot { + pub identity: SnapshotKeyPair, + pub registration_id: u32, + #[tsify(optional, type = "Uint8Array")] + #[serde(default)] + pub session: Option, + #[tsify(optional, type = "Uint8Array")] + #[serde(default)] + pub peer_identity: Option, + #[serde(default)] + pub pre_keys: Vec, + #[serde(default)] + pub signed_pre_keys: Vec, + #[tsify(optional, type = "Uint8Array")] + #[serde(default)] + pub sender_key: Option, +} + +/// What to write back. Every field is optional on purpose: absent means the +/// operation did not touch that record, so the caller must not rewrite it. +#[derive(Serialize, Tsify, Default)] +#[serde(rename_all = "camelCase")] +pub struct SignalChanges { + #[tsify(optional, type = "Uint8Array")] + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, + /// The peer's identity changed, so the session built on the old one is void: + /// delete the session row and do not write `session`. + pub session_cleared: bool, + #[tsify(optional, type = "Uint8Array")] + #[serde(skip_serializing_if = "Option::is_none")] + pub identity: Option, + /// A one-time pre-key this operation consumed. Delete it together with the + /// session write, never before — see the core's `consumed_prekey_id` note. + #[tsify(optional)] + #[serde(skip_serializing_if = "Option::is_none")] + pub removed_pre_key_id: Option, + #[tsify(optional, type = "Uint8Array")] + #[serde(skip_serializing_if = "Option::is_none")] + pub sender_key: Option, +} + +impl From for SignalChanges { + fn from(c: SnapshotChanges) -> Self { + Self { + session: c.session.map(serde_bytes::ByteBuf::from), + session_cleared: c.session_cleared, + identity: c.identity.map(serde_bytes::ByteBuf::from), + removed_pre_key_id: c.removed_pre_key, + sender_key: c.sender_key.map(serde_bytes::ByteBuf::from), + } + } +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct DecryptOutput { + #[tsify(type = "Uint8Array")] + pub plaintext: serde_bytes::ByteBuf, + pub changes: SignalChanges, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct EncryptOutput { + #[tsify(type = "Uint8Array")] + pub ciphertext: serde_bytes::ByteBuf, + /// libsignal message type: 3 = prekey message, 2 = whisper message. + pub message_type: u8, + pub changes: SignalChanges, +} + +fn build_store(snapshot: SignalSnapshot) -> Result { + let identity_public = with_prefix(snapshot.identity.public); + let identity_key = IdentityKey::try_from(identity_public.as_slice()) + .map_err(|e| err("snapshot.identity.public", e))?; + let identity_private = PrivateKey::deserialize(&snapshot.identity.private) + .map_err(|e| err("snapshot.identity.private", e))?; + + let store = SnapshotStore::new( + IdentityKeyPair::new(identity_key, identity_private), + snapshot.registration_id, + ); + + if let Some(bytes) = snapshot.session { + let record = + SessionRecord::deserialize(bytes.as_ref()).map_err(|e| err("snapshot.session", e))?; + store.with_session(record); + } + + if let Some(bytes) = snapshot.peer_identity { + store.with_peer_identity_bytes(with_prefix(bytes.into_vec())); + } + + for pre_key in snapshot.pre_keys { + let pair = KeyPair::from_public_and_private( + &with_prefix(pre_key.key_pair.public), + &pre_key.key_pair.private, + ) + .map_err(|e| err("snapshot.preKeys", e))?; + store.with_pre_key( + pre_key.id, + PreKeyRecord::new(PreKeyId::from(pre_key.id), &pair), + ); + } + + for signed in snapshot.signed_pre_keys { + let pair = KeyPair::from_public_and_private( + &with_prefix(signed.key_pair.public), + &signed.key_pair.private, + ) + .map_err(|e| err("snapshot.signedPreKeys", e))?; + store.with_signed_pre_key( + signed.id, + SignedPreKeyRecord::new( + SignedPreKeyId::from(signed.id), + Timestamp::from_epoch_millis(0), + &pair, + &signed.signature, + ), + ); + } + + if let Some(bytes) = snapshot.sender_key { + let record = SenderKeyRecord::deserialize(bytes.as_ref()) + .map_err(|e| err("snapshot.senderKey", e))?; + store.with_sender_key(record); + } + + Ok(store) +} + +#[wasm_bindgen(js_name = decryptWhisperWithSnapshot)] +pub async fn decrypt_whisper_with_snapshot( + snapshot: SignalSnapshot, + address: &ProtocolAddress, + ciphertext: &[u8], +) -> Result { + let store = build_store(snapshot)?; + let message = SignalMessage::try_from(ciphertext) + .map_err(|e| err("decryptWhisper: invalid message", e))?; + + let mut sessions = store.clone(); + let mut identities = store.clone(); + + let result = message_decrypt_signal( + &message, + &address.0, + &mut sessions, + &mut identities, + &mut rand::make_rng::(), + ) + .await + .map_err(|e| err("decryptWhisper failed", format!("{e:?}")))?; + + Ok(DecryptOutput { + plaintext: serde_bytes::ByteBuf::from(result.plaintext), + changes: store.take_changes().into(), + }) +} + +#[wasm_bindgen(js_name = decryptPreKeyWithSnapshot)] +pub async fn decrypt_prekey_with_snapshot( + snapshot: SignalSnapshot, + address: &ProtocolAddress, + ciphertext: &[u8], +) -> Result { + let store = build_store(snapshot)?; + let message = PreKeySignalMessage::try_from(ciphertext) + .map_err(|e| err("decryptPreKey: invalid message", e))?; + + let mut sessions = store.clone(); + let mut identities = store.clone(); + let mut pre_keys = store.clone(); + let signed_pre_keys = store.clone(); + + let result = message_decrypt_prekey( + &message, + &address.0, + &mut sessions, + &mut identities, + &mut pre_keys, + &signed_pre_keys, + &mut rand::make_rng::(), + UsePQRatchet::No, + ) + .await + .map_err(|e| err("decryptPreKey failed", format!("{e:?}")))?; + + // The core reports the consumed pre-key instead of deleting it, so the + // caller can drop it in the same write that makes the session durable. + let mut changes: SignalChanges = store.take_changes().into(); + changes.removed_pre_key_id = result.consumed_prekey_id.map(u32::from); + + Ok(DecryptOutput { + plaintext: serde_bytes::ByteBuf::from(result.plaintext), + changes, + }) +} + +#[wasm_bindgen(js_name = encryptWithSnapshot)] +pub async fn encrypt_with_snapshot( + snapshot: SignalSnapshot, + address: &ProtocolAddress, + plaintext: &[u8], +) -> Result { + let store = build_store(snapshot)?; + + let mut sessions = store.clone(); + let mut identities = store.clone(); + + let message = message_encrypt(plaintext, &address.0, &mut sessions, &mut identities) + .await + .map_err(|e| err("encrypt failed", format!("{e:?}")))?; + + let message_type = message.message_type() as u8; + let ciphertext = message.serialize().to_vec(); + + Ok(EncryptOutput { + ciphertext: serde_bytes::ByteBuf::from(ciphertext), + message_type, + changes: store.take_changes().into(), + }) +} + + +/// A peer's published bundle, as the server hands it over. +#[derive(Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotBundlePreKey { + pub key_id: u32, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub public_key: Vec, +} + +#[derive(Deserialize, Tsify)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotBundleSignedPreKey { + pub key_id: u32, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub public_key: Vec, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub signature: Vec, +} + +#[derive(Deserialize, Tsify)] +#[tsify(from_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotBundle { + pub registration_id: u32, + #[tsify(type = "Uint8Array")] + #[serde(with = "serde_bytes")] + pub identity_key: Vec, + #[tsify(optional)] + #[serde(default)] + pub pre_key: Option, + pub signed_pre_key: SnapshotBundleSignedPreKey, +} + +#[derive(Serialize, Tsify)] +#[tsify(into_wasm_abi)] +#[serde(rename_all = "camelCase")] +pub struct BundleOutput { + pub changes: SignalChanges, +} + +/// Builds an outgoing session from a peer bundle. Same contract as the rest of +/// this module: nothing is written, everything comes back in `changes`. +#[wasm_bindgen(js_name = processBundleWithSnapshot)] +pub async fn process_bundle_with_snapshot( + snapshot: SignalSnapshot, + address: &ProtocolAddress, + bundle: SnapshotBundle, +) -> Result { + let store = build_store(snapshot)?; + + let pre_key = bundle + .pre_key + .map(|pk| { + CorePublicKey::deserialize(&pk.public_key) + .map(|key| (pk.key_id.into(), key)) + .map_err(|e| err("bundle.preKey", e)) + }) + .transpose()?; + + let signed_public = CorePublicKey::deserialize(&bundle.signed_pre_key.public_key) + .map_err(|e| err("bundle.signedPreKey", e))?; + let identity_key = + IdentityKey::decode(&bundle.identity_key).map_err(|e| err("bundle.identityKey", e))?; + + let core_bundle = PreKeyBundle::new( + bundle.registration_id, + address.0.device_id(), + pre_key, + bundle.signed_pre_key.key_id.into(), + signed_public, + bundle.signed_pre_key.signature, + identity_key, + ) + .map_err(|e| err("bundle", e))?; + + let mut sessions = store.clone(); + let mut identities = store.clone(); + + process_prekey_bundle( + &address.0, + &mut sessions, + &mut identities, + &core_bundle, + &mut rand::make_rng::(), + UsePQRatchet::No, + ) + .await + .map_err(|e| err("processBundle failed", format!("{e:?}")))?; + + Ok(BundleOutput { + changes: store.take_changes().into(), + }) +} diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs new file mode 100644 index 00000000000..12d21839411 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -0,0 +1,255 @@ +//! In-memory store served from a caller-provided snapshot. +//! +//! The callback-based `JsStorageAdapter` calls back into JS several times per +//! Signal operation. That re-entrancy is what forces the JS side to predict +//! which records an operation will touch (so it can lock them up front), and it +//! lets a nested JS scope acquire locks in an order that inverts against +//! another scope's — the two failure modes we hit in production. +//! +//! Here the caller hands over everything the operation can need, the protocol +//! runs entirely in memory, and the mutations come back as an explicit +//! changeset. One read, one write, no callbacks in between. +//! +//! State lives behind `Rc>` so the store can be cloned into the +//! several `&mut dyn` slots `message_encrypt`/`message_decrypt_*` expect while +//! every handle still refers to the same snapshot. + +use async_trait::async_trait; +use std::cell::RefCell; +use std::collections::HashMap; +use std::rc::Rc; + +use wacore_libsignal::protocol::error::{Result as SignalResult, SignalProtocolError}; +use wacore_libsignal::protocol::{ + Direction, IdentityChange, IdentityKey, IdentityKeyPair, IdentityKeyStore, PreKeyId, + PreKeyRecord, PreKeyStore, ProtocolAddress, SenderKeyRecord, SenderKeyStore, SessionRecord, + SessionStore, SignedPreKeyId, SignedPreKeyRecord, SignedPreKeyStore, +}; +use wacore_libsignal::store::sender_key_name::SenderKeyName as CoreSenderKeyName; + +/// What the operation changed. An absent field means the record was untouched, +/// so the caller must leave it alone rather than write a stale copy back. +#[derive(Default, Clone)] +pub struct SnapshotChanges { + pub session: Option>, + /// The peer's identity was replaced, so the session built on the old key is + /// void: delete the row instead of writing `session`. + pub session_cleared: bool, + pub identity: Option>, + pub removed_pre_key: Option, + pub sender_key: Option>, +} + +struct Inner { + identity_key_pair: IdentityKeyPair, + registration_id: u32, + session: Option, + peer_identity: Option>, + pre_keys: HashMap, + signed_pre_keys: HashMap, + sender_key: Option, + changes: SnapshotChanges, +} + +#[derive(Clone)] +pub struct SnapshotStore { + inner: Rc>, +} + +impl SnapshotStore { + pub fn new(identity_key_pair: IdentityKeyPair, registration_id: u32) -> Self { + Self { + inner: Rc::new(RefCell::new(Inner { + identity_key_pair, + registration_id, + session: None, + peer_identity: None, + pre_keys: HashMap::new(), + signed_pre_keys: HashMap::new(), + sender_key: None, + changes: SnapshotChanges::default(), + })), + } + } + + pub fn with_session(&self, record: SessionRecord) { + self.inner.borrow_mut().session = Some(record); + } + + pub fn with_peer_identity_bytes(&self, identity: Vec) { + self.inner.borrow_mut().peer_identity = Some(identity); + } + + pub fn with_pre_key(&self, id: u32, record: PreKeyRecord) { + self.inner.borrow_mut().pre_keys.insert(id, record); + } + + pub fn with_signed_pre_key(&self, id: u32, record: SignedPreKeyRecord) { + self.inner.borrow_mut().signed_pre_keys.insert(id, record); + } + + pub fn with_sender_key(&self, record: SenderKeyRecord) { + self.inner.borrow_mut().sender_key = Some(record); + } + + pub fn take_changes(&self) -> SnapshotChanges { + std::mem::take(&mut self.inner.borrow_mut().changes) + } +} + +#[async_trait(?Send)] +impl SessionStore for SnapshotStore { + async fn load_session(&self, _address: &ProtocolAddress) -> SignalResult> { + Ok(self.inner.borrow().session.clone()) + } + + async fn has_session(&self, _address: &ProtocolAddress) -> SignalResult { + Ok(self.inner.borrow().session.is_some()) + } + + async fn store_session( + &mut self, + _address: &ProtocolAddress, + record: SessionRecord, + ) -> SignalResult<()> { + let bytes = record.serialize()?; + let mut inner = self.inner.borrow_mut(); + inner.changes.session = Some(bytes); + inner.changes.session_cleared = false; + inner.session = Some(record); + Ok(()) + } +} + +#[async_trait(?Send)] +impl IdentityKeyStore for SnapshotStore { + async fn get_identity_key_pair(&self) -> SignalResult { + Ok(self.inner.borrow().identity_key_pair.clone()) + } + + async fn get_local_registration_id(&self) -> SignalResult { + Ok(self.inner.borrow().registration_id) + } + + async fn save_identity( + &mut self, + _address: &ProtocolAddress, + identity: &IdentityKey, + ) -> SignalResult { + let bytes = identity.serialize().to_vec(); + let mut inner = self.inner.borrow_mut(); + let previous = inner.peer_identity.replace(bytes.clone()); + inner.changes.identity = Some(bytes.clone()); + + match previous { + Some(old) if old != bytes => { + // Trust on first use, but a REPLACED key voids the session built + // on the old one. Report it so the caller deletes that row. + inner.session = None; + inner.changes.session = None; + inner.changes.session_cleared = true; + Ok(IdentityChange::ReplacedExisting) + } + _ => Ok(IdentityChange::NewOrUnchanged), + } + } + + async fn is_trusted_identity( + &self, + _address: &ProtocolAddress, + _identity: &IdentityKey, + _direction: Direction, + ) -> SignalResult { + // TOFU, matching the JS storage and WhatsApp Web. + Ok(true) + } + + async fn get_identity(&self, _address: &ProtocolAddress) -> SignalResult> { + let inner = self.inner.borrow(); + match &inner.peer_identity { + Some(bytes) => Ok(Some(IdentityKey::decode(bytes)?)), + None => Ok(None), + } + } +} + +#[async_trait(?Send)] +impl PreKeyStore for SnapshotStore { + async fn get_pre_key(&self, prekey_id: PreKeyId) -> SignalResult { + self.inner + .borrow() + .pre_keys + .get(&u32::from(prekey_id)) + .cloned() + .ok_or(SignalProtocolError::InvalidPreKeyId) + } + + async fn save_pre_key( + &mut self, + prekey_id: PreKeyId, + record: &PreKeyRecord, + ) -> SignalResult<()> { + self.inner + .borrow_mut() + .pre_keys + .insert(u32::from(prekey_id), record.clone()); + Ok(()) + } + + async fn remove_pre_key(&mut self, prekey_id: PreKeyId) -> SignalResult<()> { + let id = u32::from(prekey_id); + let mut inner = self.inner.borrow_mut(); + inner.pre_keys.remove(&id); + // Reported, not applied: the caller deletes it alongside the session + // write so a crash cannot strip the pre-key while the session is still + // volatile (see the core's `consumed_prekey_id` note). + inner.changes.removed_pre_key = Some(id); + Ok(()) + } +} + +#[async_trait(?Send)] +impl SignedPreKeyStore for SnapshotStore { + async fn get_signed_pre_key(&self, id: SignedPreKeyId) -> SignalResult { + self.inner + .borrow() + .signed_pre_keys + .get(&u32::from(id)) + .cloned() + .ok_or(SignalProtocolError::InvalidSignedPreKeyId) + } + + async fn save_signed_pre_key( + &mut self, + id: SignedPreKeyId, + record: &SignedPreKeyRecord, + ) -> SignalResult<()> { + self.inner + .borrow_mut() + .signed_pre_keys + .insert(u32::from(id), record.clone()); + Ok(()) + } +} + +#[async_trait(?Send)] +impl SenderKeyStore for SnapshotStore { + async fn store_sender_key( + &mut self, + _sender_key_name: &CoreSenderKeyName, + record: SenderKeyRecord, + ) -> SignalResult<()> { + let bytes = record.serialize()?; + let mut inner = self.inner.borrow_mut(); + inner.changes.sender_key = Some(bytes); + inner.sender_key = Some(record); + Ok(()) + } + + async fn load_sender_key( + &self, + _sender_key_name: &CoreSenderKeyName, + ) -> SignalResult> { + Ok(self.inner.borrow().sender_key.clone()) + } +} diff --git a/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts new file mode 100644 index 00000000000..4bc56e015ac --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "@jest/globals"; +import { + ProtocolAddress, + SessionBuilder, + decryptPreKeyWithSnapshot, + decryptWhisperWithSnapshot, + encryptWithSnapshot, + generateIdentityKeyPair, + generatePreKey, + generateRegistrationId, + generateSignedPreKey, + type SignalChanges, + type SignalSnapshot, +} from "../dist/index.js"; +import { FakeStorage } from "./helpers/fake_storage"; + +/** + * The snapshot API is the whole point of this layer: an operation reads only + * what the caller handed it and reports every mutation back, so the caller can + * hold one lock and land one write. These tests pin that contract — no + * callbacks, no hidden writes, effects surfaced explicitly. + */ + +/** The WASM layer rejects with plain strings, so capture instead of toThrow(). */ +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + return String(error); + } + + throw new Error("expected the operation to reject, but it resolved"); +} + +const prefixed = (key: Uint8Array) => + key.length === 33 ? key : Uint8Array.from([5, ...key]); + +type Party = { + identity: { public: Uint8Array; private: Uint8Array }; + registrationId: number; + signedPreKey: ReturnType; + preKey: ReturnType; +}; + +function makeParty(): Party { + const identity = generateIdentityKeyPair(); + return { + identity: { public: identity.pubKey, private: identity.privKey }, + registrationId: generateRegistrationId(), + signedPreKey: generateSignedPreKey(identity, 1), + preKey: generatePreKey(1), + }; +} + +function snapshotOf( + party: Party, + extra: Partial = {}, +): SignalSnapshot { + return { + identity: party.identity, + registrationId: party.registrationId, + preKeys: [{ id: 1, keyPair: { public: party.preKey.keyPair.pubKey, private: party.preKey.keyPair.privKey } }], + signedPreKeys: [ + { + id: 1, + keyPair: { + public: party.signedPreKey.keyPair.pubKey, + private: party.signedPreKey.keyPair.privKey, + }, + signature: party.signedPreKey.signature, + }, + ], + ...extra, + } as SignalSnapshot; +} + +/** Opens a session from alice towards bob using the callback API (setup only). */ +async function establish(alice: Party, bob: Party, bobAddr: ProtocolAddress) { + const storage = new FakeStorage(); + storage.ourIdentityKeyPair = { + pubKey: prefixed(alice.identity.public), + privKey: alice.identity.private, + }; + storage.ourRegistrationId = alice.registrationId; + + const builder = new SessionBuilder(storage as never, bobAddr); + await builder.initOutgoing({ + registrationId: bob.registrationId, + identityKey: prefixed(bob.identity.public), + preKey: { keyId: 1, publicKey: prefixed(bob.preKey.keyPair.pubKey) }, + signedPreKey: { + keyId: 1, + publicKey: prefixed(bob.signedPreKey.keyPair.pubKey), + signature: bob.signedPreKey.signature, + }, + }); + + const session = await storage.loadSession(bobAddr.toString()); + if (!session) throw new Error("session was not established"); + return session as Uint8Array; +} + +describe("snapshot API", () => { + const aliceAddr = () => new ProtocolAddress("alice", 1); + const bobAddr = () => new ProtocolAddress("bob", 1); + + it("encrypts from a snapshot and reports the new session as a change", async () => { + const alice = makeParty(); + const bob = makeParty(); + const session = await establish(alice, bob, bobAddr()); + + const out = await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode("hello"), + ); + + expect(out.ciphertext.length).toBeGreaterThan(0); + // A fresh session still owes bob a prekey message. + expect(out.messageType).toBe(3); + // The mutation is reported, never applied behind the caller's back. + expect(out.changes.session).toBeDefined(); + expect(out.changes.sessionCleared).toBe(false); + }); + + it("round-trips a message between two parties through snapshots only", async () => { + const alice = makeParty(); + const bob = makeParty(); + const aliceSession = await establish(alice, bob, bobAddr()); + + const sent = await encryptWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + new TextEncoder().encode("ping"), + ); + + const received = await decryptPreKeyWithSnapshot( + snapshotOf(bob), + aliceAddr(), + sent.ciphertext, + ); + + expect(new TextDecoder().decode(received.plaintext)).toBe("ping"); + expect(received.changes.session).toBeDefined(); + // bob consumed the one-time prekey: the caller must delete exactly this id. + expect(received.changes.removedPreKeyId).toBe(1); + }); + + it("carries a conversation across several turns using the returned changes", async () => { + const alice = makeParty(); + const bob = makeParty(); + let aliceSession = await establish(alice, bob, bobAddr()); + let bobSession: Uint8Array | undefined; + + // alice -> bob (prekey message) + const first = await encryptWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + new TextEncoder().encode("m1"), + ); + aliceSession = first.changes.session!; + const firstIn = await decryptPreKeyWithSnapshot(snapshotOf(bob), aliceAddr(), first.ciphertext); + bobSession = firstIn.changes.session!; + expect(new TextDecoder().decode(firstIn.plaintext)).toBe("m1"); + + // bob -> alice, then alice -> bob again: both sides advance their chains + // using nothing but the changesets. + const reply = await encryptWithSnapshot( + snapshotOf(bob, { session: bobSession }), + aliceAddr(), + new TextEncoder().encode("m2"), + ); + bobSession = reply.changes.session!; + const replyIn = await decryptWhisperWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + reply.ciphertext, + ); + aliceSession = replyIn.changes.session!; + expect(new TextDecoder().decode(replyIn.plaintext)).toBe("m2"); + + const third = await encryptWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + new TextEncoder().encode("m3"), + ); + const thirdIn = await decryptWhisperWithSnapshot( + snapshotOf(bob, { session: bobSession }), + aliceAddr(), + third.ciphertext, + ); + expect(new TextDecoder().decode(thirdIn.plaintext)).toBe("m3"); + }); + + it("advances the sending chain monotonically across calls", async () => { + const alice = makeParty(); + const bob = makeParty(); + let session = await establish(alice, bob, bobAddr()); + + const produced: string[] = []; + for (let i = 0; i < 5; i++) { + const out = await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode(`m${i}`), + ); + produced.push(Buffer.from(out.ciphertext).toString("base64")); + session = out.changes.session!; + } + + // Reusing a chain index would repeat a ciphertext; each must be distinct. + expect(new Set(produced).size).toBe(5); + }); + + it("does not mutate the snapshot it was given", async () => { + const alice = makeParty(); + const bob = makeParty(); + const session = await establish(alice, bob, bobAddr()); + const before = Buffer.from(session).toString("base64"); + + await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode("hello"), + ); + + // The caller owns its buffers: the operation reports changes instead. + expect(Buffer.from(session).toString("base64")).toBe(before); + }); + + it("reports no changes when the operation fails", async () => { + const alice = makeParty(); + const bobAddress = bobAddr(); + + // No session in the snapshot: encryption cannot proceed, and it must say so + // rather than inventing one. + const message = await rejection( + encryptWithSnapshot(snapshotOf(alice), bobAddress, new TextEncoder().encode("x")), + ); + expect(message).toContain("SessionNotFound"); + }); + + it("rejects a snapshot missing the prekey a message needs", async () => { + const alice = makeParty(); + const bob = makeParty(); + const session = await establish(alice, bob, bobAddr()); + + const sent = await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode("ping"), + ); + + // bob's snapshot omits the one-time prekey the message consumes: the + // operation must fail loudly instead of silently establishing a session + // the peer will not recognise. + const withoutPreKey = { ...snapshotOf(bob), preKeys: [] } as SignalSnapshot; + const message = await rejection( + decryptPreKeyWithSnapshot(withoutPreKey, aliceAddr(), sent.ciphertext), + ); + expect(message).toContain("PreKey"); + }); + + it("keeps changes typed as optional so untouched records stay untouched", async () => { + const alice = makeParty(); + const bob = makeParty(); + const session = await establish(alice, bob, bobAddr()); + + const out = await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode("hello"), + ); + + const changes: SignalChanges = out.changes; + // Encryption touches the session; it must not claim a sender-key or a + // consumed prekey, which would make the caller write records it should not. + expect(changes.senderKey).toBeUndefined(); + expect(changes.removedPreKeyId).toBeUndefined(); + }); +}); From bdcb9e7320b5c65895df522eec6fa64e2d822580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:09:29 -0300 Subject: [PATCH 27/71] feat(signal): move the session path onto the snapshot API decryptMessage, encryptMessage and injectE2ESession now read a snapshot, run the operation, and apply the returned changes in a single write under one lock. A prekey message names the key it consumes, so that record is read up front instead of being fetched mid-operation. This drops the pinned-resolution storage wrapper and the identity parsing that existed to work around re-entrancy, and turns rejections into Errors, since the bridge rejects with plain strings and callers read .message. --- packages/baileys/src/Signal/libsignal.ts | 307 +++++++++++------- .../__tests__/Signal/snapshot-session.test.ts | 246 ++++++++++++++ 2 files changed, 432 insertions(+), 121 deletions(-) create mode 100644 packages/baileys/src/__tests__/Signal/snapshot-session.test.ts diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 1820d56cc18..5fd09959f8c 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -1,17 +1,19 @@ import { Boom } from '@hapi/boom' import { LRUCache } from 'lru-cache' -import type { SignalStorage } from 'whatsapp-rust-bridge' +import type { SignalChanges, SignalSnapshot, SignalStorage } from 'whatsapp-rust-bridge' import { + decryptPreKeyWithSnapshot, + decryptWhisperWithSnapshot, + encryptWithSnapshot, GroupCipher, GroupSessionBuilder, hasLogger, importLegacySessionRecordV1, + processBundleWithSnapshot, ProtocolAddress, SenderKeyDistributionMessage, SenderKeyName, SenderKeyRecord, - SessionBuilder, - SessionCipher, SessionRecord, setLogger } from 'whatsapp-rust-bridge' @@ -20,6 +22,7 @@ import type { LIDMapping, RecordRef, SignalAuthState, + SignalDataSet, SignalKeyStoreWithRecordTransaction, SignalKeyStoreWithTransaction } from '../Types' @@ -71,6 +74,18 @@ async function resolveSignalAddressId(id: string, lidMapping: LIDMappingStore): return id } +/** + * The bridge rejects with plain strings. Callers match on `.message` and log + * `err.stack`, so hand them a real Error while keeping the text intact. + */ +function asError(error: unknown): Error { + if (error instanceof Error) { + return error + } + + return new Error(typeof error === 'string' ? error : String(error)) +} + /** * The JS libsignal reported an already-consumed message key as * `MISSING_KEYS_ERROR_TEXT`, which the receive path matches to ACK the stanza @@ -79,13 +94,13 @@ async function resolveSignalAddressId(id: string, lidMapping: LIDMappingStore): * strings, not Errors — so map it back onto the text messages-recv understands. * Without this a redelivered ciphertext drives a pointless retry/resend loop. */ -function normalizeDecryptError(error: unknown): unknown { +function normalizeDecryptError(error: unknown): Error { const message = typeof error === 'string' ? error : error instanceof Error ? error.message : '' if (message.includes('DuplicatedMessage')) { return new Boom(MISSING_KEYS_ERROR_TEXT, { data: { cause: message } }) } - return error + return asError(error) } /** Does a stored session row — bridge bytes or a pre-WASM record — hold a live state? */ @@ -105,28 +120,16 @@ function hasOpenSession(stored: Uint8Array | undefined): boolean { } } -/** Extract identity key from PreKeyWhisperMessage for identity change detection */ -function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined { +/** + * The one-time pre-key a prekey message will consume, read from its header so + * the caller can put it in the snapshot instead of the operation fetching it. + */ +function preKeyIdsFrom(ciphertext: Uint8Array): number[] { try { - if (!ciphertext || ciphertext.length < 2) { - return undefined - } - - // Version byte check (version 3) - const version = ciphertext[0]! - if ((version & 0xf) !== 3) { - return undefined - } - - // Parse protobuf (skip version byte) - const preKeyProto = proto.PreKeySignalMessage.decode(ciphertext.slice(1)) - if (preKeyProto.identityKey?.length === 33) { - return new Uint8Array(preKeyProto.identityKey) - } - - return undefined + const parsed = proto.PreKeySignalMessage.decode(ciphertext.slice(1)) + return typeof parsed.preKeyId === 'number' ? [parsed.preKeyId] : [] } catch { - return undefined + return [] } } @@ -144,28 +147,11 @@ export function makeLibSignalRepository( // Bound delegate so repository methods can pre-resolve before acquiring locks. const resolveLIDSignalAddress = (id: string) => resolveSignalAddressId(id, lidMapping) - /** - * Build a per-call `signalStorage` instance whose resolver returns - * `wireJid` for `rawAddr` (and falls through to the standard `lidMapping` - * lookup for any other id libsignal might pass — e.g. the participant - * field on a sender-key operation, which is not the one we pre-resolved). - * - * This is what closes the TOCTOU on resolved-address divergence: the - * outer transactWith locks on `wireJid`, and every storage call libsignal - * makes inside that scope uses the SAME `wireJid` for the row it - * reads/writes — no independent re-resolution that could land on a - * different row if the mapping changed mid-flight. - */ - const pinResolutionForStorage = (rawAddr: string, wireJid: string) => - signalStorage(auth, lidMapping, async (id: string) => { - if (id === rawAddr) return wireJid - return resolveSignalAddressId(id, lidMapping) - }) - // Baileys' `addTransactionCapability` returns a store with `transactWith` // implemented; narrow to the record-transaction variant so the internal // call sites don't have to null-check the (publicly optional) method. const parsedKeys = auth.keys as SignalKeyStoreWithRecordTransaction + const { creds } = auth const migratedSessionCache = new LRUCache({ ttl: 3 * 24 * 60 * 60 * 1000, // 3 days ttlAutopurge: true, @@ -184,6 +170,118 @@ export function makeLibSignalRepository( return { senderName, skdm } } + /** + * Runs one Signal operation over the peer's session. + * + * The backend gets a snapshot and hands back a changeset, so the operation + * never calls into this store while it runs. That is what lets a single lock + * on the session record be enough: there is no nested scope to acquire a + * second record, and therefore no pair of scopes that can take the same two + * records in opposite orders. + * + * Everything the operation reported lands in one `set`, so a crash cannot + * leave the pre-key deleted while the session it belongs to is still the old + * one. + */ + const withSession = async ( + jid: string, + run: (snapshot: SignalSnapshot, address: ProtocolAddress) => Promise<{ changes: SignalChanges } & T>, + extra?: { preKeyIds?: number[] } + ): Promise => { + const address = jidToSignalProtocolAddress(jid) + const wireJid = await resolveLIDSignalAddress(address.toString()) + + return parsedKeys.transactWith({ records: [{ type: 'session', id: wireJid }] }, async () => { + const [sessions, identities] = await Promise.all([ + parsedKeys.get('session', [wireJid]), + parsedKeys.get('identity-key', [wireJid]) + ]) + + const preKeys: SignalSnapshot['preKeys'] = [] + for (const id of extra?.preKeyIds ?? []) { + const { [id]: stored } = await parsedKeys.get('pre-key', [String(id)]) + if (stored) { + preKeys.push({ id, keyPair: { public: stored.public, private: stored.private } }) + } + } + + const snapshot: SignalSnapshot = { + identity: { + public: creds.signedIdentityKey.public, + private: creds.signedIdentityKey.private + }, + registrationId: creds.registrationId, + session: await readSessionBytes(sessions[wireJid]), + peerIdentity: identities[wireJid], + preKeys, + signedPreKeys: [ + { + id: creds.signedPreKey.keyId, + keyPair: { + public: creds.signedPreKey.keyPair.public, + private: creds.signedPreKey.keyPair.private + }, + signature: creds.signedPreKey.signature + } + ] + } + + let result: { changes: SignalChanges } & T + try { + result = await run(snapshot, address) + } catch (error) { + // Nothing was written yet, so a failure leaves storage exactly as + // it was found — no half-applied session. + throw asError(error) + } + + const { changes, ...rest } = result + await applyChanges(wireJid, changes) + return rest as T + }) + } + + /** Pre-WASM records are converted on read; bridge records pass straight through. */ + const readSessionBytes = async (stored: unknown): Promise => { + if (!stored) return undefined + + if (isLegacySessionRecord(stored)) { + if (!hasOpenLegacySession(stored)) return undefined + return importLegacySessionRecordV1(toTypedRecord(stored), { + identityKey: generateSignalPubKey(creds.signedIdentityKey.public), + registrationId: creds.registrationId + }) + } + + return stored as Uint8Array + } + + /** + * One write for everything the operation touched. Absent fields are left + * alone: rewriting an untouched record would clobber a concurrent update. + */ + const applyChanges = async (wireJid: string, changes: SignalChanges) => { + const update: SignalDataSet = {} + + if (changes.sessionCleared) { + update.session = { [wireJid]: null } + } else if (changes.session) { + update.session = { [wireJid]: changes.session } + } + + if (changes.identity) { + update['identity-key'] = { [wireJid]: changes.identity } + } + + if (changes.removedPreKeyId !== undefined) { + update['pre-key'] = { [changes.removedPreKeyId]: null } + } + + if (Object.keys(update).length) { + await parsedKeys.set(update) + } + } + const repository: SignalRepositoryWithLIDStore = { decryptGroupMessage({ group, authorJid, msg }) { const senderName = jidToSignalSenderKeyName(group, authorJid) @@ -226,78 +324,42 @@ export function makeLibSignalRepository( }) }, async decryptMessage({ jid, type, ciphertext }) { - const addr = jidToSignalProtocolAddress(jid) - const addrStr = addr.toString() - - // Pre-resolve the wire id ONCE, then thread it through libsignal's - // internal storage calls via a pinned `signalStorage` instance. - // Without pinning, `storage.loadSession`/`storeSession`/`saveIdentity` - // each independently re-resolve via `lidMapping` — if a mapping - // change lands between our pre-resolve (for the lock) and any of - // those internal calls, the actual row hit can drift away from - // the locked record. Pinning makes the entire operation use - // `wireJid` everywhere `addrStr` would appear. - const wireJid = await resolveLIDSignalAddress(addrStr) - const pinnedStorage = pinResolutionForStorage(addrStr, wireJid) - const session = new SessionCipher(pinnedStorage, addr) - - // H1 fix: pkmsg identity-key save runs INSIDE the per-jid transaction - // scope, sharing the session+identity-key locks with the decrypt - // itself. Previously the save happened before the transaction was - // opened, so a concurrent send for the same jid could load the stale - // session under its own meId-keyed lock and then commit it back over - // the cleared one. - const records: RecordRef[] = [{ type: 'session', id: wireJid }] - if (type === 'pkmsg') { - records.push({ type: 'identity-key', id: wireJid }) - } - - async function doDecrypt() { - let result: Uint8Array - try { - switch (type) { - case 'pkmsg': - result = await session.decryptPreKeyWhisperMessage(ciphertext) - break - case 'msg': - result = await session.decryptWhisperMessage(ciphertext) - break - } - } catch (error) { - throw normalizeDecryptError(error) - } - - return result - } - - return parsedKeys.transactWith({ records }, async () => { - if (type === 'pkmsg') { - const identityKey = extractIdentityFromPkmsg(ciphertext) - if (identityKey) { - const identityChanged = await pinnedStorage.saveIdentity(addrStr, identityKey) - if (identityChanged) { - logger.info({ jid, addr: addrStr }, 'identity key changed or new contact, session will be re-established') + // A prekey message names the one-time key it consumes, so the snapshot + // can carry it: the operation never has to reach back for a record. + const preKeyIds = type === 'pkmsg' ? preKeyIdsFrom(ciphertext) : [] + + const { plaintext } = await withSession( + jid, + async (snapshot, address) => { + try { + const out = + type === 'pkmsg' + ? await decryptPreKeyWithSnapshot(snapshot, address, ciphertext) + : await decryptWhisperWithSnapshot(snapshot, address, ciphertext) + + if (out.changes.sessionCleared) { + logger.info({ jid }, 'identity key changed, session will be re-established') } + + return { plaintext: out.plaintext, changes: out.changes } + } catch (error) { + throw normalizeDecryptError(error) } - } + }, + { preKeyIds } + ) - return await doDecrypt() - }) + return plaintext }, async encryptMessage({ jid, data }) { - const addr = jidToSignalProtocolAddress(jid) - const addrStr = addr.toString() - // Pre-resolve + pin so libsignal's internal storage calls use the - // same wire id we lock on (see decryptMessage for rationale). - const wireJid = await resolveLIDSignalAddress(addrStr) - const pinnedStorage = pinResolutionForStorage(addrStr, wireJid) - const cipher = new SessionCipher(pinnedStorage, addr) - - return parsedKeys.transactWith({ records: [{ type: 'session', id: wireJid }] }, async () => { - const { type: sigType, body } = await cipher.encrypt(data) - const type = sigType === 3 ? 'pkmsg' : 'msg' - return { type, ciphertext: body } + return withSession(jid, async (snapshot, address) => { + const out = await encryptWithSnapshot(snapshot, address, data) + return { + type: out.messageType === 3 ? ('pkmsg' as const) : ('msg' as const), + ciphertext: out.ciphertext, + changes: out.changes + } }) }, @@ -359,19 +421,22 @@ export function makeLibSignalRepository( async injectE2ESession({ jid, session }) { logger.trace({ jid }, 'injecting E2EE session') - const addr = jidToSignalProtocolAddress(jid) - const addrStr = addr.toString() - // Pre-resolve + pin so libsignal's internal storage calls use the - // same wire id we lock on (see decryptMessage for rationale). - const wireJid = await resolveLIDSignalAddress(addrStr) - const pinnedStorage = pinResolutionForStorage(addrStr, wireJid) - const cipher = new SessionBuilder(pinnedStorage, addr) - return parsedKeys.transactWith({ records: [{ type: 'session', id: wireJid }] }, async () => { - // libsignal runtime accepts an absent prekey (initOutgoing checks `device.preKey && ...`) - // but the bundled .d.ts marks it required. - await cipher.initOutgoing(session as unknown as Parameters[0]) + await withSession(jid, async (snapshot, address) => { + const out = await processBundleWithSnapshot(snapshot, address, { + registrationId: session.registrationId, + identityKey: session.identityKey, + preKey: session.preKey ? { keyId: session.preKey.keyId, publicKey: session.preKey.publicKey } : undefined, + signedPreKey: { + keyId: session.signedPreKey.keyId, + publicKey: session.signedPreKey.publicKey, + signature: session.signedPreKey.signature + } + }) + + return { changes: out.changes } }) }, + jidToSignalProtocolAddress(jid) { return jidToSignalProtocolAddress(jid).toString() }, @@ -573,7 +638,7 @@ export function makeLibSignalRepository( // record was silently skipped, which preserved it by accident; // now that the copy is real the check has to be explicit. if (hasOpenLegacySession(pnSession) && !hasOpenSession(lidSessions[lidAddrStr])) { - sessionUpdates[lidAddrStr] = pnSession as unknown as Uint8Array + sessionUpdates[lidAddrStr] = pnSession sessionUpdates[pnAddrStr] = null migratedCount++ diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts new file mode 100644 index 00000000000..62fa9963cf6 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' + +/** + * The session path reads a snapshot, runs the protocol with no callbacks, and + * lands every mutation in one write. These tests pin that shape from the + * Baileys side: what the store sees, and when. A regression here means the + * bridge is reaching back into JS mid-operation again — the re-entrancy that + * made two scopes take the same records in opposite orders. + */ + +const logger = P({ level: 'silent' }) + +type Call = { op: 'get' | 'set'; types: string[]; ids: string[] } + +const makeRecordingStore = () => { + const data: { [type: string]: { [id: string]: unknown } } = {} + const calls: Call[] = [] + + const store: SignalKeyStore = { + get: async (type, ids) => { + calls.push({ op: 'get', types: [type], ids: [...ids] }) + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) { + out[id] = bucket[id] as SignalDataTypeMap[typeof type] + } + } + + return out + }, + set: async (update: SignalDataSet) => { + const types = Object.keys(update) + calls.push({ + op: 'set', + types, + ids: types.flatMap(type => Object.keys(update[type as keyof SignalDataSet]!)) + }) + for (const type of types) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type][id] + else data[type][id] = value + } + } + } + } + + return { store, calls, data } +} + +const makeParty = () => { + const creds = initAuthCreds() + const recorder = makeRecordingStore() + const keys = addTransactionCapability(recorder.store, logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1 + }) + const auth: SignalAuthState = { creds, keys } + + return { auth, creds, recorder, repository: makeLibSignalRepository(auth, logger) } +} + +const bundleOf = async (party: ReturnType, preKeyId: number) => { + const preKey = initAuthCreds().signedPreKey.keyPair + await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) + + return { + registrationId: party.creds.registrationId, + identityKey: generateSignalPubKey(party.creds.signedIdentityKey.public), + preKey: { keyId: preKeyId, publicKey: generateSignalPubKey(preKey.public) }, + signedPreKey: { + keyId: party.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(party.creds.signedPreKey.keyPair.public), + signature: party.creds.signedPreKey.signature + } + } +} + +const aliceJid = '1111111111@s.whatsapp.net' +const bobJid = '2222222222@s.whatsapp.net' + +describe('snapshot session path', () => { + it('lands an encrypt in a single write with no reads in between', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + + alice.recorder.calls.length = 0 + await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('hello') }) + + const writes = alice.recorder.calls.filter(call => call.op === 'set') + expect(writes).toHaveLength(1) + // Encrypt pins the peer identity alongside the session; both land together. + expect(writes[0]!.types.sort()).toEqual(['identity-key', 'session']) + + // Every read must precede the single write: a read after it would mean + // the operation went back to storage mid-flight. + const write = alice.recorder.calls.findIndex(call => call.op === 'set') + expect(alice.recorder.calls.slice(write + 1).some(call => call.op === 'get')).toBe(false) + }) + + it('deletes the consumed pre-key in the same write that stores the session', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 7) }) + const { ciphertext, type } = await alice.repository.encryptMessage({ + jid: bobJid, + data: Buffer.from('hi') + }) + expect(type).toBe('pkmsg') + + bob.recorder.calls.length = 0 + await bob.repository.decryptMessage({ jid: aliceJid, type, ciphertext }) + + const writes = bob.recorder.calls.filter(call => call.op === 'set') + expect(writes).toHaveLength(1) + // Session and spent pre-key move together: a crash between them would + // either strip a key the session still needs or leave it reusable. + expect(writes[0]!.types.sort()).toEqual(['identity-key', 'pre-key', 'session']) + expect(bob.recorder.data['pre-key']?.[7]).toBeUndefined() + }) + + it('reads the pre-key the incoming message names, not the whole keyspace', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 42) }) + const message = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('hi') }) + + bob.recorder.calls.length = 0 + await bob.repository.decryptMessage({ jid: aliceJid, ...message }) + + const preKeyReads = bob.recorder.calls.filter(call => call.op === 'get' && call.types[0] === 'pre-key') + expect(preKeyReads).toHaveLength(1) + expect(preKeyReads[0]!.ids).toEqual(['42']) + }) + + it('does not touch the pre-key store for a plain whisper message', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + const first = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('one') }) + await bob.repository.decryptMessage({ jid: aliceJid, ...first }) + + const reply = await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('two') }) + expect(reply.type).toBe('msg') + + alice.recorder.calls.length = 0 + const plaintext = await alice.repository.decryptMessage({ jid: bobJid, ...reply }) + + expect(Buffer.from(plaintext).toString()).toBe('two') + expect(alice.recorder.calls.some(call => call.types.includes('pre-key'))).toBe(false) + }) + + it('leaves storage untouched when the operation fails', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + const message = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('hi') }) + + const corrupted = Uint8Array.from(message.ciphertext) + corrupted[corrupted.length - 1] = corrupted[corrupted.length - 1]! ^ 0xff + + bob.recorder.calls.length = 0 + await expect( + bob.repository.decryptMessage({ jid: aliceJid, type: message.type, ciphertext: corrupted }) + ).rejects.toThrow() + + // A failed operation reports no changes, so nothing may be written — + // half-applied state is what corrupts a session. + expect(bob.recorder.calls.filter(call => call.op === 'set')).toHaveLength(0) + expect(bob.recorder.data['session']).toBeUndefined() + }) + + it('keeps the chain monotonic when encrypts overlap on one session', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + + const produced = await Promise.all( + Array.from({ length: 8 }, (_, index) => + alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`m${index}`) }) + ) + ) + + // Two encrypts reading the same session state would reuse a chain index + // and repeat a ciphertext — the failure the server reports as + // "message with old counter". + const distinct = new Set(produced.map(out => Buffer.from(out.ciphertext).toString('base64'))) + expect(distinct.size).toBe(produced.length) + }) + + it('delivers every message when encrypt and decrypt interleave on one session', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + + const opening = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('open') }) + await bob.repository.decryptMessage({ jid: aliceJid, ...opening }) + + // bob answers while still deciphering what alice sends: both directions + // hit the same record at once. + const inbound: Promise[] = [] + const outbound: Promise[] = [] + for (let index = 0; index < 6; index++) { + outbound.push(bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from(`pong${index}`) })) + inbound.push( + alice.repository + .encryptMessage({ jid: bobJid, data: Buffer.from(`ping${index}`) }) + .then(message => bob.repository.decryptMessage({ jid: aliceJid, ...message })) + ) + } + + const received = (await Promise.all(inbound)) as Uint8Array[] + expect(received.map(plaintext => Buffer.from(plaintext).toString()).sort()).toEqual([ + 'ping0', + 'ping1', + 'ping2', + 'ping3', + 'ping4', + 'ping5' + ]) + + const replies = (await Promise.all(outbound)) as { type: 'pkmsg' | 'msg'; ciphertext: Uint8Array }[] + const decoded = await Promise.all( + replies.map(reply => + alice.repository.decryptMessage({ jid: bobJid, type: reply.type, ciphertext: reply.ciphertext }) + ) + ) + expect(decoded.map(plaintext => Buffer.from(plaintext).toString()).sort()).toEqual([ + 'pong0', + 'pong1', + 'pong2', + 'pong3', + 'pong4', + 'pong5' + ]) + }) +}) From a7e26ab0310ae8044f117487c35e924b19e1d577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:36:19 -0300 Subject: [PATCH 28/71] fix(signal): address device 99 and live LID sessions during migration Migration probed only `user.` and then re-derived the address from the jid, which for device 99 resolves to the hosted form. A row written under either shape was therefore never matched, and building the destination through transferDevice produced a plain LID jid, which device 99 is rejected for. Probe both shapes, carry the address the row was found under, and send device 99 to the hosted LID domain. The bridge-bytes branch also copied over the destination without checking it, so a session established on the LID key after the PN one was overwritten. It now takes the same guard as the legacy branch. --- packages/baileys/src/Signal/libsignal.ts | 51 ++++++--- .../__tests__/Signal/legacy-session.test.ts | 101 ++++++++++++++++++ 2 files changed, 138 insertions(+), 14 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 5fd09959f8c..da6a506a7a2 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -36,6 +36,7 @@ import { isLidUser, isPnUser, jidDecode, + jidEncode, transferDevice, WAJIDDomains } from '../WABinary' @@ -533,15 +534,27 @@ export function makeLibSignalRepository( return !migratedSessionCache.has(deviceKey) }) - // Bulk check session existence only for uncached devices - const deviceSessionKeys = uncachedDevices.map(device => `${user}.${device}`) + // Bulk check session existence only for uncached devices. Device 99 is + // always hosted, so its row may sit under either the plain or the + // hosted address depending on whether the mapping was known when the + // session was created. Probe both, or a live session is left stranded + // once lookups move to the LID side. + const deviceSessionKeys: string[] = [] + for (const device of uncachedDevices) { + deviceSessionKeys.push(`${user}.${device}`) + if (device === '99') { + deviceSessionKeys.push(`${user}_${WAJIDDomains.HOSTED}.${device}`) + } + } + const existingSessions = await parsedKeys.get('session', deviceSessionKeys) - // Step 3: Convert existing sessions to JIDs (only migrate sessions that exist) - const deviceJids: string[] = [] + // Step 3: Convert existing sessions to JIDs (only migrate sessions that exist). + // The address the row was FOUND under is carried forward: re-deriving it + // from the jid would address `user_128.99` for a row stored at `user.99`. + const found: { jid: string; addrStr: string }[] = [] for (const [sessionKey, sessionData] of Object.entries(existingSessions)) { if (sessionData) { - // Session exists in storage const deviceStr = sessionKey.split('.')[1] if (!deviceStr) continue const deviceNum = parseInt(deviceStr) @@ -550,10 +563,12 @@ export function makeLibSignalRepository( jid = `${user}:99@hosted` } - deviceJids.push(jid) + found.push({ jid, addrStr: sessionKey }) } } + const deviceJids = found.map(entry => entry.jid) + logger.debug( { fromJid, @@ -574,12 +589,18 @@ export function makeLibSignalRepository( pnUser: string lidUser: string deviceId: number - fromAddr: ProtocolAddress + fromAddrStr: string toAddr: ProtocolAddress } - const migrationOps: MigrationOp[] = deviceJids.map(jid => { - const lidWithDevice = transferDevice(jid, toJid) + const migrationOps: MigrationOp[] = found.map(({ jid, addrStr }) => { + // transferDevice carries the destination's server, which for a LID + // target is `lid`. Device 99 only exists on the hosted side, and + // addressing it as plain LID is rejected outright. + const lidWithDevice = + jidDecode(jid)!.device === 99 + ? transferDevice(jid, jidEncode(jidDecode(toJid)!.user, 'hosted.lid')) + : transferDevice(jid, toJid) const fromDecoded = jidDecode(jid)! const toDecoded = jidDecode(lidWithDevice)! @@ -589,14 +610,14 @@ export function makeLibSignalRepository( pnUser: fromDecoded.user, lidUser: toDecoded.user, deviceId: fromDecoded.device || 0, - fromAddr: jidToSignalProtocolAddress(jid), + fromAddrStr: addrStr, toAddr: jidToSignalProtocolAddress(lidWithDevice) } }) const sessionRecords: RecordRef[] = [] for (const op of migrationOps) { - sessionRecords.push({ type: 'session', id: op.fromAddr.toString() }) + sessionRecords.push({ type: 'session', id: op.fromAddrStr }) sessionRecords.push({ type: 'session', id: op.toAddr.toString() }) } @@ -609,7 +630,7 @@ export function makeLibSignalRepository( let migratedCount = 0 // Bulk fetch PN sessions - already exist (verified during device discovery) - const pnAddrStrings = Array.from(new Set(migrationOps.map(op => op.fromAddr.toString()))) + const pnAddrStrings = Array.from(new Set(migrationOps.map(op => op.fromAddrStr))) const pnSessions = await parsedKeys.get('session', pnAddrStrings) // Destination rows, needed to avoid overwriting a live LID session // with a legacy PN one. Both sides are already inside the lock scope. @@ -620,7 +641,7 @@ export function makeLibSignalRepository( const sessionUpdates: { [key: string]: Uint8Array | null } = {} for (const op of migrationOps) { - const pnAddrStr = op.fromAddr.toString() + const pnAddrStr = op.fromAddrStr const lidAddrStr = op.toAddr.toString() const pnSession = pnSessions[pnAddrStr] @@ -649,7 +670,9 @@ export function makeLibSignalRepository( // Session exists (guaranteed from device discovery) const fromSession = SessionRecord.deserialize(pnSession) - if (fromSession.haveOpenSession()) { + // Same rule as the legacy branch above: a live session already on + // the LID key is newer than this one and must not be overwritten. + if (fromSession.haveOpenSession() && !hasOpenSession(lidSessions[lidAddrStr])) { // Queue for bulk update: copy to LID, delete from PN sessionUpdates[lidAddrStr] = fromSession.serialize() sessionUpdates[pnAddrStr] = null diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 8e45afcbaec..6e42307ad74 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -10,6 +10,8 @@ import { import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' +import { WAJIDDomains } from '../../WABinary' const logger = P({ level: 'silent' }) @@ -214,6 +216,105 @@ describe('repository on a pre-WASM auth state', () => { expect(data.session![addr]).toBeDefined() }) + /** Bytes of a real bridge session, exactly as injectE2ESession stores them. */ + const bridgeSessionBytes = async (jid: string, preKeyId: number): Promise => { + const peerCreds = initAuthCreds() + const { repository, data } = makeRepository() + await repository.injectE2ESession({ + jid, + session: { + registrationId: peerCreds.registrationId, + identityKey: generateSignalPubKey(peerCreds.signedIdentityKey.public), + preKey: { + keyId: preKeyId, + publicKey: generateSignalPubKey(peerCreds.signedPreKey.keyPair.public) + }, + signedPreKey: { + keyId: peerCreds.signedPreKey.keyId, + publicKey: generateSignalPubKey(peerCreds.signedPreKey.keyPair.public), + signature: peerCreds.signedPreKey.signature + } + } + }) + + const stored = Object.values(data.session!)[0] + return stored as Uint8Array + } + + it('keeps a live LID session instead of overwriting it with a post-upgrade PN one', async () => { + // Same rule as the legacy case, reached through the bridge-bytes branch: + // a session written under the PN key before the mapping was known must + // not clobber a newer one already on the LID key. + const lidJid = '18000000000001@lid' + const lidAddr = '18000000000001_1.0' + const older = await bridgeSessionBytes(pnJid, 1) + const newer = await bridgeSessionBytes(lidJid, 2) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['0'] } }) + // Seeded outside the constructor: it JSON round-trips the seed, which + // would turn these byte arrays into plain objects. + data.session = { [addr]: older, [lidAddr]: newer } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(0) + // The LID row must still hold the newer session, byte for byte. + expect(Buffer.from(data.session![lidAddr] as Uint8Array).toString('base64')).toBe( + Buffer.from(newer).toString('base64') + ) + expect(data.session![addr]).toBeDefined() + }) + + it('migrates a post-upgrade PN session when the LID key is free', async () => { + const lidJid = '18000000000002@lid' + const lidAddr = '18000000000002_1.0' + const pnBytes = await bridgeSessionBytes(pnJid, 3) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['0'] } }) + data.session = { [addr]: pnBytes } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + expect(data.session![lidAddr]).toBeDefined() + expect(data.session![addr]).toBeUndefined() + }) + + it('migrates a device-99 session stored under the hosted address', async () => { + // A device-99 session created before the mapping was known lands on the + // hosted address. Discovery used to probe only `user.99`, so the row was + // never found and went stale once lookups moved to the LID side. + const lidJid = '18000000000003@lid' + const hostedAddr = `5511900000001_${WAJIDDomains.HOSTED}.99` + const pnBytes = await bridgeSessionBytes('5511900000001:99@hosted', 4) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['99'] } }) + data.session = { [hostedAddr]: pnBytes } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + // The row moves to the hosted-LID address and the source is cleared. + expect(data.session![`18000000000003_${WAJIDDomains.HOSTED_LID}.99`]).toBeDefined() + expect(data.session![hostedAddr]).toBeUndefined() + }) + + it('migrates a device-99 session stored under the plain address', async () => { + // The other half of the same problem: the row exists at `user.99`, but the + // address was re-derived from the jid as `user_128.99`, so nothing matched. + const lidJid = '18000000000004@lid' + const plainAddr = '5511900000001.99' + const pnBytes = await bridgeSessionBytes('5511900000001:99@hosted', 5) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['99'] } }) + data.session = { [plainAddr]: pnBytes } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + expect(data.session![plainAddr]).toBeUndefined() + }) + it('does not migrate a legacy record whose states are all closed', async () => { const lidJid = '18000000000001@lid' const closedOnly = { From 1a68decd1d5317c67c89121bf6abd2ec58260304 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:36:29 -0300 Subject: [PATCH 29/71] fix(bridge): review fixes for the snapshot path - key the address cache by name AND device, since two devices of one user share a name and the cache would hand back the wrong address - keep a pre-key removal reported by the store when the core names none - log, rather than raise, a failure to delete a consumed pre-key: the message is already decrypted and dropping it would lose it to a cleanup problem - refuse save_pre_key and save_signed_pre_key on the snapshot store, which has no way to report those writes back to the caller - validate the output path in the rollback fixture, and run cargo fmt --- .../src/__tests__/fixtures/rollback-step1.ts | 7 +++- .../src/legacy_session.rs | 25 +++++++------ packages/whatsapp-rust-bridge/src/lib.rs | 4 +- .../src/session_cipher.rs | 19 ++++------ .../whatsapp-rust-bridge/src/snapshot_api.rs | 19 ++++++---- .../src/snapshot_store.rs | 37 +++++++++++-------- .../src/storage_adapter.rs | 26 +++++++++---- 7 files changed, 81 insertions(+), 56 deletions(-) diff --git a/packages/baileys/src/__tests__/fixtures/rollback-step1.ts b/packages/baileys/src/__tests__/fixtures/rollback-step1.ts index b2d8501a88a..33d058ae030 100644 --- a/packages/baileys/src/__tests__/fixtures/rollback-step1.ts +++ b/packages/baileys/src/__tests__/fixtures/rollback-step1.ts @@ -94,8 +94,13 @@ const main = async () => { throw new Error(`session is not projectable: ${JSON.stringify(projection.issue)}`) } + const outputPath = process.argv[2] + if (!outputPath) { + throw new Error('usage: rollback-step1 ') + } + writeFileSync( - process.argv[2]!, + outputPath, JSON.stringify({ outgoing: { type: outgoing.type, ct: Buffer.from(outgoing.ciphertext).toString('base64') }, projectedSession: fromTypedRecord(projection.record), diff --git a/packages/whatsapp-rust-bridge/src/legacy_session.rs b/packages/whatsapp-rust-bridge/src/legacy_session.rs index f8bc72e5073..a875546ef2e 100644 --- a/packages/whatsapp-rust-bridge/src/legacy_session.rs +++ b/packages/whatsapp-rust-bridge/src/legacy_session.rs @@ -4,7 +4,6 @@ use bytes::Bytes; use js_sys::Uint8Array; use serde::{Deserialize, Serialize}; use tsify::Tsify; -use wasm_bindgen::prelude::*; use wacore_libsignal::protocol::{ IdentityKey, LegacyIndexedSessionV1 as CoreIndexedSession, LegacySessionBaseKeyRoleV1 as CoreBaseKeyRole, LegacySessionChainCounterV1 as CoreChainCounter, @@ -17,6 +16,7 @@ use wacore_libsignal::protocol::{ LegacySessionUnrepresentableFieldV1 as CoreUnrepresentableField, LegacySessionV1 as CoreSession, SessionRecord, }; +use wasm_bindgen::prelude::*; fn byte_array(bytes: &[u8]) -> Uint8Array { Uint8Array::from(bytes) @@ -289,9 +289,7 @@ impl From for LegacySessionKeyPairV1 { } } -impl TryFrom - for wacore_libsignal::protocol::LegacySessionIndexV1 -{ +impl TryFrom for wacore_libsignal::protocol::LegacySessionIndexV1 { type Error = CoreInteropError; fn try_from(value: LegacySessionIndexV1) -> Result { @@ -306,9 +304,7 @@ impl TryFrom } } -impl From - for LegacySessionIndexV1 -{ +impl From for LegacySessionIndexV1 { fn from(value: wacore_libsignal::protocol::LegacySessionIndexV1) -> Self { Self { base_key: value.base_key.into(), @@ -448,8 +444,9 @@ pub fn import_legacy_session_record_v1( record: LegacySessionRecordV1, context: LegacySessionLocalContext, ) -> Result { - let identity_key = IdentityKey::decode(&context.identity_key) - .map_err(|error| JsValue::from_str(&format!("{}: {}", "context.identityKey", error.to_string())))?; + let identity_key = IdentityKey::decode(&context.identity_key).map_err(|error| { + JsValue::from_str(&format!("{}: {}", "context.identityKey", error.to_string())) + })?; let record = CoreRecord::try_from(record) .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; let record = record @@ -459,7 +456,9 @@ pub fn import_legacy_session_record_v1( }) .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; let bytes = record.serialize().map_err(|error| { - JsValue::from_str(&format!("serialize imported legacy session record: {error}")) + JsValue::from_str(&format!( + "serialize imported legacy session record: {error}" + )) })?; Ok(byte_array(&bytes)) } @@ -476,7 +475,11 @@ pub fn project_legacy_session_record_v1( }), Err(error) => match projection_issue(error) { Ok(issue) => Ok(LegacySessionProjectionV1::Unrepresentable { issue }), - Err(error) => Err(JsValue::from_str(&format!("{}: {}", "recordBytes", error.to_string()))), + Err(error) => Err(JsValue::from_str(&format!( + "{}: {}", + "recordBytes", + error.to_string() + ))), }, } } diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index a4d1e9d50dc..346ee1ab983 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -15,10 +15,10 @@ pub mod noise_session; pub mod protocol_address; pub mod sender_key_name; pub mod session_builder; -pub mod snapshot_api; -pub mod snapshot_store; pub mod session_cipher; pub mod session_record; +pub mod snapshot_api; +pub mod snapshot_store; #[cfg(feature = "sticker")] pub mod sticker_metadata; pub mod storage_adapter; diff --git a/packages/whatsapp-rust-bridge/src/session_cipher.rs b/packages/whatsapp-rust-bridge/src/session_cipher.rs index b4cd13246f0..00792888988 100644 --- a/packages/whatsapp-rust-bridge/src/session_cipher.rs +++ b/packages/whatsapp-rust-bridge/src/session_cipher.rs @@ -100,17 +100,14 @@ impl SessionCipher { })?; // The core reports the one-time key it consumed rather than deleting it, - // so this path has to do the delete itself — otherwise a spent pre-key - // stays in storage and can be handed out again. - if let Some(id) = plaintext.consumed_prekey_id { - PreKeyStore::remove_pre_key(&mut prekey_store, id) - .await - .map_err(|e| { - JsValue::from_str(&format!( - "SessionCipher.decryptPreKeyWhisperMessage failed to remove pre-key: {:?}", - e - )) - })?; + // so this path has to do the delete itself, or a spent pre-key stays in + // storage and can be handed out again. The message is already decrypted + // at this point, so a storage failure here is logged rather than raised: + // dropping the plaintext would lose a message to a cleanup problem. + if let Some(id) = plaintext.consumed_prekey_id + && let Err(e) = PreKeyStore::remove_pre_key(&mut prekey_store, id).await + { + log::warn!("failed to remove consumed pre-key {:?}: {:?}", id, e); } Ok(bytes_to_uint8array(&plaintext.plaintext)) diff --git a/packages/whatsapp-rust-bridge/src/snapshot_api.rs b/packages/whatsapp-rust-bridge/src/snapshot_api.rs index 446a3be59d4..4504dcd002f 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_api.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_api.rs @@ -16,10 +16,10 @@ use tsify::Tsify; use wasm_bindgen::prelude::*; use wacore_libsignal::protocol::{ - GenericSignedPreKey as _, PreKeyBundle, PublicKey as CorePublicKey, process_prekey_bundle, IdentityKey, IdentityKeyPair, KeyPair, PreKeyId, PreKeyRecord, - PreKeySignalMessage, PrivateKey, SenderKeyRecord, SessionRecord, SignalMessage, SignedPreKeyId, - SignedPreKeyRecord, Timestamp, UsePQRatchet, message_decrypt_prekey, message_decrypt_signal, - message_encrypt, + GenericSignedPreKey as _, IdentityKey, IdentityKeyPair, KeyPair, PreKeyBundle, PreKeyId, + PreKeyRecord, PreKeySignalMessage, PrivateKey, PublicKey as CorePublicKey, SenderKeyRecord, + SessionRecord, SignalMessage, SignedPreKeyId, SignedPreKeyRecord, Timestamp, UsePQRatchet, + message_decrypt_prekey, message_decrypt_signal, message_encrypt, process_prekey_bundle, }; use crate::protocol_address::ProtocolAddress; @@ -229,7 +229,7 @@ pub async fn decrypt_whisper_with_snapshot( &mut rand::make_rng::(), ) .await - .map_err(|e| err("decryptWhisper failed", format!("{e:?}")))?; + .map_err(|e| err("decryptWhisper failed", format!("{e:?}")))?; Ok(DecryptOutput { plaintext: serde_bytes::ByteBuf::from(result.plaintext), @@ -266,9 +266,13 @@ pub async fn decrypt_prekey_with_snapshot( .map_err(|e| err("decryptPreKey failed", format!("{e:?}")))?; // The core reports the consumed pre-key instead of deleting it, so the - // caller can drop it in the same write that makes the session durable. + // caller can drop it in the same write that makes the session durable. Only + // overwrite when it names one: the store records the same effect if the + // core ever removes the key itself, and that must not be dropped here. let mut changes: SignalChanges = store.take_changes().into(); - changes.removed_pre_key_id = result.consumed_prekey_id.map(u32::from); + if let Some(id) = result.consumed_prekey_id { + changes.removed_pre_key_id = Some(u32::from(id)); + } Ok(DecryptOutput { plaintext: serde_bytes::ByteBuf::from(result.plaintext), @@ -301,7 +305,6 @@ pub async fn encrypt_with_snapshot( }) } - /// A peer's published bundle, as the server hands it over. #[derive(Deserialize, Tsify)] #[serde(rename_all = "camelCase")] diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index 12d21839411..eb044f14485 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -99,7 +99,10 @@ impl SnapshotStore { #[async_trait(?Send)] impl SessionStore for SnapshotStore { - async fn load_session(&self, _address: &ProtocolAddress) -> SignalResult> { + async fn load_session( + &self, + _address: &ProtocolAddress, + ) -> SignalResult> { Ok(self.inner.borrow().session.clone()) } @@ -186,14 +189,18 @@ impl PreKeyStore for SnapshotStore { async fn save_pre_key( &mut self, - prekey_id: PreKeyId, - record: &PreKeyRecord, + _prekey_id: PreKeyId, + _record: &PreKeyRecord, ) -> SignalResult<()> { - self.inner - .borrow_mut() - .pre_keys - .insert(u32::from(prekey_id), record.clone()); - Ok(()) + // The changeset has no slot for a written pre-key, because the core only + // calls this from its own tests. Storing it in the snapshot would drop + // the write silently when the operation returns, so refuse instead: if + // the core ever starts using it, this fails loudly rather than losing a + // key the caller was never told to persist. + Err(SignalProtocolError::InvalidState( + "save_pre_key", + "the snapshot store cannot report a written pre-key".to_owned(), + )) } async fn remove_pre_key(&mut self, prekey_id: PreKeyId) -> SignalResult<()> { @@ -221,14 +228,14 @@ impl SignedPreKeyStore for SnapshotStore { async fn save_signed_pre_key( &mut self, - id: SignedPreKeyId, - record: &SignedPreKeyRecord, + _id: SignedPreKeyId, + _record: &SignedPreKeyRecord, ) -> SignalResult<()> { - self.inner - .borrow_mut() - .signed_pre_keys - .insert(u32::from(id), record.clone()); - Ok(()) + // Same reasoning as save_pre_key above. + Err(SignalProtocolError::InvalidState( + "save_signed_pre_key", + "the snapshot store cannot report a written signed pre-key".to_owned(), + )) } } diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index be137714ac3..69f1f362265 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1,5 +1,6 @@ use async_trait::async_trait; use base64::prelude::*; +use buffa::{Message as _, MessageField}; use js_sys::{Promise, Uint8Array}; use serde::Deserialize; use serde::de::DeserializeOwned; @@ -7,7 +8,6 @@ use serde_bytes::ByteBuf; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; -use buffa::{Message as _, MessageField}; use waproto::whatsapp::{ RecordStructure, SenderKeyRecordStructure, SenderKeyStateStructure, SessionStructure, sender_key_state_structure::{SenderChainKey, SenderMessageKey, SenderSigningKey}, @@ -20,10 +20,10 @@ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; use wacore_libsignal::protocol::{ - self as libsignal, Direction as StoreDirection, GenericSignedPreKey as _, IdentityChange, - IdentityKey, IdentityKeyPair, IdentityKeyStore, KeyPair, PreKeyId, PreKeyRecord, PreKeyStore, - PrivateKey, SenderKeyStore, SessionStore, SignedPreKeyId, SignedPreKeyRecord, - SignedPreKeyStore, + self as libsignal, DeviceId, Direction as StoreDirection, GenericSignedPreKey as _, + IdentityChange, IdentityKey, IdentityKeyPair, IdentityKeyStore, KeyPair, PreKeyId, + PreKeyRecord, PreKeyStore, PrivateKey, SenderKeyStore, SessionStore, SignedPreKeyId, + SignedPreKeyRecord, SignedPreKeyStore, }; type SignalResult = wacore_libsignal::protocol::error::Result; @@ -129,7 +129,7 @@ pub struct JsStorageAdapter { cached_sender_keys: Rc>>, cached_identities: Rc>>>, has_store_session_raw: Rc>>, - last_address_cache: Rc>>, + last_address_cache: Rc>>, last_sender_key_cache: Rc>>, } @@ -197,10 +197,15 @@ impl JsStorageAdapter { #[inline] fn get_address_string(&self, address: &libsignal::ProtocolAddress) -> String { + // Two devices of one user share a name, so the device id has to be part + // of the key: matching on the name alone would hand back another + // device's address. let name = address.name(); + let device = address.device_id(); let cache = self.last_address_cache.borrow(); - if let Some((cached_name, cached_str)) = cache.as_ref() + if let Some((cached_name, cached_device, cached_str)) = cache.as_ref() && cached_name == name + && *cached_device == device { return cached_str.clone(); } @@ -209,7 +214,7 @@ impl JsStorageAdapter { let addr_str = address.to_string(); self.last_address_cache .borrow_mut() - .replace((name.to_string(), addr_str.clone())); + .replace((name.to_string(), device, addr_str.clone())); addr_str } @@ -957,6 +962,11 @@ impl IdentityKeyStore for JsStorageAdapter { Ok(trusted) } + // Identity rows are keyed by the full address, matching their session. A + // pre-fix store holds them under the bare user, and those rows are simply + // left behind: they are not read again, and re-learning an identity is + // harmless under trust-on-first-use, whereas deleting rows on upgrade risks + // dropping one that is still in use. async fn save_identity( &mut self, address: &libsignal::ProtocolAddress, From 7a1274e72e9d35cf666522fc1df2c13c246daf45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:36:29 -0300 Subject: [PATCH 30/71] ci: build the bridge from source when a change touches it pnpm install copies the last PUBLISHED bridge tarball into the workspace, so a change to the Rust crate is invisible to every other package until it ships. A PR that adds an export fails with "does not provide an export named ...", which says nothing about the code under review and cannot be fixed without publishing first. Move the toolchain setup into a composite action that compiles the crate when the pull request touches it, and keep the prebuilt otherwise. bridge-build.yml now shares the same action instead of repeating the pinned Binaryen setup. --- .github/actions/setup-workspace/action.yml | 144 +++++++++++++++++++++ .github/workflows/bridge-build.yml | 71 +--------- .github/workflows/build.yml | 19 +-- .github/workflows/lint.yml | 19 +-- .github/workflows/test.yml | 19 +-- 5 files changed, 169 insertions(+), 103 deletions(-) create mode 100644 .github/actions/setup-workspace/action.yml diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml new file mode 100644 index 00000000000..5c312120e21 --- /dev/null +++ b/.github/actions/setup-workspace/action.yml @@ -0,0 +1,144 @@ +name: Setup workspace +description: >- + Installs workspace dependencies, building whatsapp-rust-bridge from source + when the change touches it. + +# `pnpm install` runs a postinstall hook that copies the last PUBLISHED bridge +# tarball into the workspace, so a change to the Rust crate or its TS wrapper is +# invisible to every other package until it is released. A PR that adds an +# export therefore fails here with "does not provide an export named ...", +# which says nothing about the code under review. +# +# When the change touches the bridge, skip that download and compile the crate +# instead, so the suite runs against the source in the PR. Otherwise keep the +# prebuilt: it is the same artifact consumers get, and it costs seconds. + +inputs: + node-version: + description: Node version to install + required: false + default: 20.x + pnpm-version: + description: pnpm version to install + required: false + default: 10.28.2 + +outputs: + bridge-built: + description: Whether the bridge was compiled from source + value: ${{ steps.detect.outputs.changed }} + +runs: + using: composite + + steps: + - name: Detect bridge changes + id: detect + shell: bash + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + # On a push to a protected branch there is no diff to consult and the + # published bridge may already be behind, so always build. + if [ "$EVENT_NAME" != "pull_request" ] && [ "$EVENT_NAME" != "pull_request_target" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Not a pull request; building the bridge from source." + exit 0 + fi + + # Ask the API rather than diffing, which would need the full history. + if ! files="$(gh api --paginate \ + "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/files" \ + --jq '.[].filename')"; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "::warning::Could not read the changed files; building the bridge from source." + exit 0 + fi + + if printf '%s\n' "$files" | grep -qE '^packages/whatsapp-rust-bridge/'; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Bridge touched; building it from source." + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Bridge untouched; using the published prebuilt." + fi + + - uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm-version }} + + - uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: 'pnpm' + + - name: Install Rust toolchain (pinned via rust-toolchain.toml) + if: steps.detect.outputs.changed == 'true' + shell: bash + working-directory: packages/whatsapp-rust-bridge + run: rustup show + + - name: Install wasm-pack + if: steps.detect.outputs.changed == 'true' + uses: taiki-e/install-action@v2 + with: + tool: wasm-pack + + # Ubuntu's Binaryen is too old for the wasm-opt flags scripts/build-wasm.mjs + # passes. Keep this pinned pair in step with .github/workflows/bridge-build.yml. + - name: Cache Binaryen + if: steps.detect.outputs.changed == 'true' + id: cache-binaryen + uses: actions/cache@v4 + with: + path: /tmp/binaryen-version_129 + key: binaryen-129-x86_64-linux + + - name: Download & verify Binaryen (wasm-opt) + if: steps.detect.outputs.changed == 'true' && steps.cache-binaryen.outputs.cache-hit != 'true' + shell: bash + env: + BINARYEN_VERSION: '129' + BINARYEN_SHA256: 50b9fa62b9abea752da92ec57e0c555fee578760cd237c40107957715d2976ba + run: | + set -euo pipefail + tarball="binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz" + curl -fsSL "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/${tarball}" -o "${tarball}" + echo "${BINARYEN_SHA256} ${tarball}" | sha256sum -c - + tar -xzf "${tarball}" -C /tmp + rm -f "${tarball}" + + - name: Add Binaryen to PATH + if: steps.detect.outputs.changed == 'true' + shell: bash + run: echo "/tmp/binaryen-version_129/bin" >> "$GITHUB_PATH" + + - name: Cache cargo registry + target + if: steps.detect.outputs.changed == 'true' + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + packages/whatsapp-rust-bridge/target + key: ${{ runner.os }}-cargo-${{ hashFiles('packages/whatsapp-rust-bridge/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Skip the prebuilt download + if: steps.detect.outputs.changed == 'true' + shell: bash + run: echo "WHATSAPP_RUST_BRIDGE_SKIP_PREBUILT=1" >> "$GITHUB_ENV" + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Build the bridge from source + if: steps.detect.outputs.changed == 'true' + shell: bash + run: pnpm --filter whatsapp-rust-bridge build diff --git a/.github/workflows/bridge-build.yml b/.github/workflows/bridge-build.yml index 4fd01cc9810..37070aeed02 100644 --- a/.github/workflows/bridge-build.yml +++ b/.github/workflows/bridge-build.yml @@ -29,18 +29,13 @@ on: permissions: contents: read + pull-requests: read jobs: build: runs-on: ubuntu-latest timeout-minutes: 25 - env: - BINARYEN_VERSION: "129" - # SHA-256 of the official binaryen-version_-x86_64-linux.tar.gz release asset. - # Bump together with BINARYEN_VERSION; verified before extraction (supply-chain guard). - BINARYEN_SHA256: 50b9fa62b9abea752da92ec57e0c555fee578760cd237c40107957715d2976ba - defaults: run: working-directory: packages/whatsapp-rust-bridge @@ -50,56 +45,9 @@ jobs: with: persist-credentials: false - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'pnpm' - - - name: Install Rust toolchain (pinned via rust-toolchain.toml) - run: rustup show - - - name: Install wasm-pack - uses: taiki-e/install-action@v2 - with: - tool: wasm-pack - - # Ubuntu's Binaryen is too old for `--gufa-optimizing` and the other flags - # scripts/build-wasm.mjs passes to wasm-opt. wasm-pack ships no compatible - # wasm-opt and taiki-e/install-action doesn't package binaryen, so fetch a - # pinned upstream release — cached across runs, checksum-verified on a miss. - - name: Cache Binaryen - id: cache-binaryen - uses: actions/cache@v4 - with: - path: /tmp/binaryen-version_${{ env.BINARYEN_VERSION }} - key: binaryen-${{ env.BINARYEN_VERSION }}-x86_64-linux - - - name: Download & verify Binaryen (wasm-opt) - if: steps.cache-binaryen.outputs.cache-hit != 'true' - run: | - tarball="binaryen-version_${BINARYEN_VERSION}-x86_64-linux.tar.gz" - curl -fsSL "https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/${tarball}" -o "${tarball}" - echo "${BINARYEN_SHA256} ${tarball}" | sha256sum -c - - tar -xzf "${tarball}" -C /tmp - rm -f "${tarball}" - - - name: Add Binaryen to PATH - run: echo "/tmp/binaryen-version_${BINARYEN_VERSION}/bin" >> "$GITHUB_PATH" - - - name: Cache cargo registry + target - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - packages/whatsapp-rust-bridge/target - key: ${{ runner.os }}-cargo-${{ hashFiles('packages/whatsapp-rust-bridge/Cargo.lock') }} - restore-keys: | - ${{ runner.os }}-cargo- + # Compiles the crate and installs the workspace against it. + - name: Setup workspace + uses: ./.github/actions/setup-workspace - name: Check Rust formatting run: cargo fmt --all -- --check @@ -107,17 +55,6 @@ jobs: - name: Lint Rust run: cargo clippy --target wasm32-unknown-unknown --all-features -- -D warnings - - name: Skip postinstall download - working-directory: ${{ github.workspace }} - run: echo "WHATSAPP_RUST_BRIDGE_SKIP_PREBUILT=1" >> "$GITHUB_ENV" - - - name: Install workspace dependencies - working-directory: ${{ github.workspace }} - run: pnpm install --frozen-lockfile - - - name: Build bridge from source - run: pnpm build - - name: Run bridge typecheck and Jest tests run: pnpm test:typecheck && pnpm test:jest diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 586ca4c4259..10116a0ce3f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,25 +6,20 @@ on: - master pull_request: +permissions: + contents: read + pull-requests: read + jobs: build: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Setup workspace + uses: ./.github/actions/setup-workspace - name: Build project run: pnpm build diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 72dfdb3e451..07a56e26b60 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,27 +6,22 @@ on: - master pull_request: +permissions: + contents: read + pull-requests: read + jobs: check-lint: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 steps: - uses: actions/checkout@v4 with: persist-credentials: false - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'pnpm' - - - name: Install packages - run: pnpm install --frozen-lockfile + - name: Setup workspace + uses: ./.github/actions/setup-workspace - name: Check linting run: pnpm lint diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 59634d19847..9bd006042fc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,10 +6,14 @@ on: - master pull_request: +permissions: + contents: read + pull-requests: read + jobs: run-tests: runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 25 steps: - name: Checkout code @@ -17,17 +21,8 @@ jobs: with: persist-credentials: false - - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile + - name: Setup workspace + uses: ./.github/actions/setup-workspace - name: Run tests run: pnpm test From af7ccd90903f65f2d1759518b610ac6efdcc9a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 03:50:11 -0300 Subject: [PATCH 31/71] style(bridge): drop redundant to_string in format args Clippy only reaches this file now that the crate is compiled in CI. --- .../whatsapp-rust-bridge/src/legacy_session.rs | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/whatsapp-rust-bridge/src/legacy_session.rs b/packages/whatsapp-rust-bridge/src/legacy_session.rs index a875546ef2e..cf20c1ff654 100644 --- a/packages/whatsapp-rust-bridge/src/legacy_session.rs +++ b/packages/whatsapp-rust-bridge/src/legacy_session.rs @@ -444,17 +444,16 @@ pub fn import_legacy_session_record_v1( record: LegacySessionRecordV1, context: LegacySessionLocalContext, ) -> Result { - let identity_key = IdentityKey::decode(&context.identity_key).map_err(|error| { - JsValue::from_str(&format!("{}: {}", "context.identityKey", error.to_string())) - })?; + let identity_key = IdentityKey::decode(&context.identity_key) + .map_err(|error| JsValue::from_str(&format!("context.identityKey: {error}")))?; let record = CoreRecord::try_from(record) - .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; + .map_err(|error| JsValue::from_str(&format!("record: {error}")))?; let record = record .into_session_record(CoreLocalContext { identity_key, registration_id: context.registration_id, }) - .map_err(|error| JsValue::from_str(&format!("{}: {}", "record", error.to_string())))?; + .map_err(|error| JsValue::from_str(&format!("record: {error}")))?; let bytes = record.serialize().map_err(|error| { JsValue::from_str(&format!( "serialize imported legacy session record: {error}" @@ -468,18 +467,14 @@ pub fn project_legacy_session_record_v1( bytes: &[u8], ) -> Result { let record = SessionRecord::deserialize(bytes) - .map_err(|error| JsValue::from_str(&format!("{}: {}", "recordBytes", error.to_string())))?; + .map_err(|error| JsValue::from_str(&format!("recordBytes: {error}")))?; match record.into_legacy_session_v1_operational() { Ok(record) => Ok(LegacySessionProjectionV1::Projected { record: record.into(), }), Err(error) => match projection_issue(error) { Ok(issue) => Ok(LegacySessionProjectionV1::Unrepresentable { issue }), - Err(error) => Err(JsValue::from_str(&format!( - "{}: {}", - "recordBytes", - error.to_string() - ))), + Err(error) => Err(JsValue::from_str(&format!("recordBytes: {error}"))), }, } } From 1a8a4e7cf003465025410fc2313b2dd865ff11ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 09:27:34 -0300 Subject: [PATCH 32/71] fix(signal): lock the identity row with the session, and pick one device-99 source Applying a changeset writes identity-key as well as session, but only the session was locked. signalStorage.saveIdentity does a read-modify-write on that same row while holding its lock, and clears the session when the key differs, so the two paths could overwrite each other. transactWith acquires refs in a fixed order, so naming both is safe. Probing both device-99 address shapes could also find two rows that share one destination: migrating both deleted both and kept whichever landed last. Take the hosted row, which is the shape written today, and leave the other in place. --- packages/baileys/src/Signal/libsignal.ts | 126 +++++++++++------- .../__tests__/Signal/legacy-session.test.ts | 24 ++++ .../__tests__/Signal/snapshot-session.test.ts | 53 +++++++- 3 files changed, 156 insertions(+), 47 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index da6a506a7a2..476655a80ad 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -192,54 +192,67 @@ export function makeLibSignalRepository( const address = jidToSignalProtocolAddress(jid) const wireJid = await resolveLIDSignalAddress(address.toString()) - return parsedKeys.transactWith({ records: [{ type: 'session', id: wireJid }] }, async () => { - const [sessions, identities] = await Promise.all([ - parsedKeys.get('session', [wireJid]), - parsedKeys.get('identity-key', [wireJid]) - ]) - - const preKeys: SignalSnapshot['preKeys'] = [] - for (const id of extra?.preKeyIds ?? []) { - const { [id]: stored } = await parsedKeys.get('pre-key', [String(id)]) - if (stored) { - preKeys.push({ id, keyPair: { public: stored.public, private: stored.private } }) + // The identity row is locked alongside the session because applying the + // changeset writes it too. signalStorage.saveIdentity holds both and can + // clear the session when the key differs, so taking only the session here + // would let the two paths overwrite each other. transactWith acquires in a + // fixed order, so naming both cannot deadlock against it. + return parsedKeys.transactWith( + { + records: [ + { type: 'session', id: wireJid }, + { type: 'identity-key', id: wireJid } + ] + }, + async () => { + const [sessions, identities] = await Promise.all([ + parsedKeys.get('session', [wireJid]), + parsedKeys.get('identity-key', [wireJid]) + ]) + + const preKeys: SignalSnapshot['preKeys'] = [] + for (const id of extra?.preKeyIds ?? []) { + const { [id]: stored } = await parsedKeys.get('pre-key', [String(id)]) + if (stored) { + preKeys.push({ id, keyPair: { public: stored.public, private: stored.private } }) + } } - } - const snapshot: SignalSnapshot = { - identity: { - public: creds.signedIdentityKey.public, - private: creds.signedIdentityKey.private - }, - registrationId: creds.registrationId, - session: await readSessionBytes(sessions[wireJid]), - peerIdentity: identities[wireJid], - preKeys, - signedPreKeys: [ - { - id: creds.signedPreKey.keyId, - keyPair: { - public: creds.signedPreKey.keyPair.public, - private: creds.signedPreKey.keyPair.private - }, - signature: creds.signedPreKey.signature - } - ] - } + const snapshot: SignalSnapshot = { + identity: { + public: creds.signedIdentityKey.public, + private: creds.signedIdentityKey.private + }, + registrationId: creds.registrationId, + session: await readSessionBytes(sessions[wireJid]), + peerIdentity: identities[wireJid], + preKeys, + signedPreKeys: [ + { + id: creds.signedPreKey.keyId, + keyPair: { + public: creds.signedPreKey.keyPair.public, + private: creds.signedPreKey.keyPair.private + }, + signature: creds.signedPreKey.signature + } + ] + } - let result: { changes: SignalChanges } & T - try { - result = await run(snapshot, address) - } catch (error) { - // Nothing was written yet, so a failure leaves storage exactly as - // it was found — no half-applied session. - throw asError(error) - } + let result: { changes: SignalChanges } & T + try { + result = await run(snapshot, address) + } catch (error) { + // Nothing was written yet, so a failure leaves storage exactly as + // it was found — no half-applied session. + throw asError(error) + } - const { changes, ...rest } = result - await applyChanges(wireJid, changes) - return rest as T - }) + const { changes, ...rest } = result + await applyChanges(wireJid, changes) + return rest as T + } + ) } /** Pre-WASM records are converted on read; bridge records pass straight through. */ @@ -552,7 +565,7 @@ export function makeLibSignalRepository( // Step 3: Convert existing sessions to JIDs (only migrate sessions that exist). // The address the row was FOUND under is carried forward: re-deriving it // from the jid would address `user_128.99` for a row stored at `user.99`. - const found: { jid: string; addrStr: string }[] = [] + const foundByDevice = new Map() for (const [sessionKey, sessionData] of Object.entries(existingSessions)) { if (sessionData) { const deviceStr = sessionKey.split('.')[1] @@ -563,10 +576,31 @@ export function makeLibSignalRepository( jid = `${user}:99@hosted` } - found.push({ jid, addrStr: sessionKey }) + const already = foundByDevice.get(deviceNum) + if (already) { + // Both historical shapes hold a row for this device, and they + // share one destination. Migrating both would delete both and + // keep whichever landed last, dropping a ratchet that may still + // be live. Take the hosted address, which is the shape written + // today, and leave the other row where it is. + const hostedIsNew = sessionKey.includes('_') + logger.warn( + { device: deviceNum, migrating: hostedIsNew ? sessionKey : already.addrStr }, + 'device has a session under both the plain and hosted address; migrating one and leaving the other' + ) + + if (hostedIsNew) { + foundByDevice.set(deviceNum, { jid, addrStr: sessionKey }) + } + + continue + } + + foundByDevice.set(deviceNum, { jid, addrStr: sessionKey }) } } + const found = Array.from(foundByDevice.values()) const deviceJids = found.map(entry => entry.jid) logger.debug( diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 6e42307ad74..949e1e41146 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -315,6 +315,30 @@ describe('repository on a pre-WASM auth state', () => { expect(data.session![plainAddr]).toBeUndefined() }) + it('keeps one device-99 row when both address shapes hold a session', async () => { + // The two shapes share a single destination. Migrating both would delete + // both and keep whichever was written last, losing a live ratchet. + const lidJid = '18000000000005@lid' + const plainAddr = '5511900000001.99' + const hostedAddr = `5511900000001_${WAJIDDomains.HOSTED}.99` + const plainBytes = await bridgeSessionBytes('5511900000001:99@hosted', 6) + const hostedBytes = await bridgeSessionBytes('5511900000001:99@hosted', 7) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['99'] } }) + data.session = { [plainAddr]: plainBytes, [hostedAddr]: hostedBytes } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + // The hosted row is the shape written today, so it is the one that moves. + expect(Buffer.from(data.session![`18000000000005_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( + Buffer.from(hostedBytes) + ) + expect(data.session![hostedAddr]).toBeUndefined() + // The other row is left alone rather than deleted along with it. + expect(data.session![plainAddr]).toBeDefined() + }) + it('does not migrate a legacy record whose states are all closed', async () => { const lidJid = '18000000000001@lid' const closedOnly = { diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index 62fa9963cf6..c3754c9d876 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from '@jest/globals' import P from 'pino' import { makeLibSignalRepository } from '../../Signal/libsignal' -import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import type { + SignalAuthState, + SignalDataSet, + SignalDataTypeMap, + SignalKeyStore, + SignalKeyStoreWithRecordTransaction +} from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' import { generateSignalPubKey } from '../../Utils/crypto' @@ -15,6 +21,15 @@ import { generateSignalPubKey } from '../../Utils/crypto' const logger = P({ level: 'silent' }) +const deferred = () => { + let resolve!: () => void + const promise = new Promise(r => (resolve = r)) + return { promise, resolve } +} + +/** Lets any already-queued microtasks and timers run before checking state. */ +const tick = () => new Promise(resolve => setTimeout(resolve, 20)) + type Call = { op: 'get' | 'set'; types: string[]; ids: string[] } const makeRecordingStore = () => { @@ -179,6 +194,42 @@ describe('snapshot session path', () => { expect(bob.recorder.data['session']).toBeUndefined() }) + it('holds the identity row too, so the group path cannot race it', async () => { + // Applying the changeset writes identity-key as well as session, and + // signalStorage.saveIdentity does a read-modify-write on that row while + // holding its lock (clearing the session when the key differs). If the + // session path took only the session lock, the two would overwrite each + // other. Holding the identity lock from outside must therefore block it. + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + + const keys = alice.auth.keys as SignalKeyStoreWithRecordTransaction + const wireJid = '2222222222.0' + + let encryptFinished = false + const release = deferred() + // Held from a scope of its own: calling encrypt inside the callback would + // nest it, and a nested scope does not contend with its parent. + const holding = keys.transactWith({ records: [{ type: 'identity-key', id: wireJid }] }, async () => { + await release.promise + }) + await tick() + + const encrypting = alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('blocked') }).then(() => { + encryptFinished = true + }) + + await Promise.race([encrypting, tick()]) + expect(encryptFinished).toBe(false) + + release.resolve() + await holding + await encrypting + await holding + expect(encryptFinished).toBe(true) + }) + it('keeps the chain monotonic when encrypts overlap on one session', async () => { const alice = makeParty() const bob = makeParty() From 318999457b66e1b58300811d02246464ae625c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 09:27:34 -0300 Subject: [PATCH 33/71] ci: force the bridge build in the job that exists to compile it bridge-build.yml also runs for lockfile and workflow changes, where the detector correctly reports the bridge as untouched. That left the job without wasm-pack while still running the build, so it now asks for the source build explicitly. Changes to the action itself trigger the workflow too, since it is the only job that exercises them. --- .github/actions/setup-workspace/action.yml | 14 ++++++++++++++ .github/workflows/bridge-build.yml | 7 +++++++ 2 files changed, 21 insertions(+) diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml index 5c312120e21..d997a7d2980 100644 --- a/.github/actions/setup-workspace/action.yml +++ b/.github/actions/setup-workspace/action.yml @@ -14,6 +14,12 @@ description: >- # prebuilt: it is the same artifact consumers get, and it costs seconds. inputs: + force-bridge-build: + description: >- + Compile the bridge regardless of what the change touches. Set by jobs that + cannot run without it, such as the bridge's own build. + required: false + default: 'false' node-version: description: Node version to install required: false @@ -36,12 +42,20 @@ runs: id: detect shell: bash env: + FORCE_BUILD: ${{ inputs.force-bridge-build }} GH_TOKEN: ${{ github.token }} EVENT_NAME: ${{ github.event_name }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail + # Some jobs cannot run against a prebuilt at all. + if [ "$FORCE_BUILD" = "true" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Bridge build forced by the caller." + exit 0 + fi + # On a push to a protected branch there is no diff to consult and the # published bridge may already be behind, so always build. if [ "$EVENT_NAME" != "pull_request" ] && [ "$EVENT_NAME" != "pull_request_target" ]; then diff --git a/.github/workflows/bridge-build.yml b/.github/workflows/bridge-build.yml index 37070aeed02..72072dcba75 100644 --- a/.github/workflows/bridge-build.yml +++ b/.github/workflows/bridge-build.yml @@ -19,6 +19,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - '.github/workflows/bridge-build.yml' + - '.github/actions/setup-workspace/**' pull_request: paths: - 'packages/whatsapp-rust-bridge/**' @@ -26,6 +27,7 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - '.github/workflows/bridge-build.yml' + - '.github/actions/setup-workspace/**' permissions: contents: read @@ -48,6 +50,11 @@ jobs: # Compiles the crate and installs the workspace against it. - name: Setup workspace uses: ./.github/actions/setup-workspace + with: + # This job exists to compile the crate, so it must never fall back to + # the prebuilt: a lockfile-only PR still triggers it and would then + # reach `pnpm build` with no wasm-pack installed. + force-bridge-build: 'true' - name: Check Rust formatting run: cargo fmt --all -- --check From 28fe323d4ab8d4887e92510810f1720e179c34eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 09:37:04 -0300 Subject: [PATCH 34/71] fix(signal): prefer the open row when a device has two address shapes Taking the hosted one unconditionally stranded the live session whenever the hosted row was the closed one. --- packages/baileys/src/Signal/libsignal.ts | 14 +++++++---- .../__tests__/Signal/legacy-session.test.ts | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 476655a80ad..6fa072078a0 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -581,15 +581,19 @@ export function makeLibSignalRepository( // Both historical shapes hold a row for this device, and they // share one destination. Migrating both would delete both and // keep whichever landed last, dropping a ratchet that may still - // be live. Take the hosted address, which is the shape written - // today, and leave the other row where it is. - const hostedIsNew = sessionKey.includes('_') + // be live. Prefer whichever row is still open; if that does not + // separate them, take the hosted address, which is the shape + // written today. The row not chosen is left where it is. + const candidateOpen = hasOpenSession(sessionData) + const incumbentOpen = hasOpenSession(existingSessions[already.addrStr]) + const takeCandidate = candidateOpen !== incumbentOpen ? candidateOpen : sessionKey.includes('_') + logger.warn( - { device: deviceNum, migrating: hostedIsNew ? sessionKey : already.addrStr }, + { device: deviceNum, migrating: takeCandidate ? sessionKey : already.addrStr }, 'device has a session under both the plain and hosted address; migrating one and leaving the other' ) - if (hostedIsNew) { + if (takeCandidate) { foundByDevice.set(deviceNum, { jid, addrStr: sessionKey }) } diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 949e1e41146..825f887e007 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -339,6 +339,29 @@ describe('repository on a pre-WASM auth state', () => { expect(data.session![plainAddr]).toBeDefined() }) + it('picks the open device-99 row when the hosted one is closed', async () => { + // Preferring the hosted shape unconditionally would strand the live + // session whenever the hosted row is the dead one. + const lidJid = '18000000000006@lid' + const plainAddr = '5511900000001.99' + const hostedAddr = `5511900000001_${WAJIDDomains.HOSTED}.99` + const liveBytes = await bridgeSessionBytes('5511900000001:99@hosted', 8) + + const { repository, data } = makeRepository({ 'device-list': { '5511900000001': ['99'] } }) + // An empty record deserialises to a session with no open state. + data.session = { [plainAddr]: liveBytes, [hostedAddr]: new Uint8Array([0]) } + + const result = await repository.migrateSession(pnJid, lidJid) + + expect(result.migrated).toBe(1) + expect(Buffer.from(data.session![`18000000000006_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( + Buffer.from(liveBytes) + ) + expect(data.session![plainAddr]).toBeUndefined() + // The closed row is left behind rather than migrated. + expect(data.session![hostedAddr]).toBeDefined() + }) + it('does not migrate a legacy record whose states are all closed', async () => { const lidJid = '18000000000001@lid' const closedOnly = { From f5a807b30eed45d03724523218e9aef47856ef50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 10:02:28 -0300 Subject: [PATCH 35/71] perf(bridge): report the peer identity only when it changes The core reasserts trust on first use during every encrypt and decrypt, and the store reported that as a change each time, so the caller rewrote the identity row on every message, once per device. Report it only when the key is new or different, which is what the callback path already did. Adds coverage for the identity-change path, where the old session is voided and a new one is built in the same operation, and pins the stored identity to the 33-byte prefixed form a pre-WASM release also writes. --- .../__tests__/Signal/identity-format.test.ts | 69 +++++++++++++++++++ .../__tests__/Signal/snapshot-session.test.ts | 57 ++++++++++++++- .../src/snapshot_store.rs | 8 ++- 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 packages/baileys/src/__tests__/Signal/identity-format.test.ts diff --git a/packages/baileys/src/__tests__/Signal/identity-format.test.ts b/packages/baileys/src/__tests__/Signal/identity-format.test.ts new file mode 100644 index 00000000000..a0be9c241fa --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/identity-format.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { generateSignalPubKey } from '../../Utils/crypto' + +const logger = P({ level: 'silent' }) +const mk = () => { + const data: { [t: string]: { [i: string]: unknown } } = {} + const store: SignalKeyStore = { + get: async (type, ids) => { + const b = data[type] || {} + const o: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) if (b[id] !== undefined && b[id] !== null) o[id] = b[id] as never + return o + }, + set: async (u: SignalDataSet) => { + for (const t of Object.keys(u)) { + data[t] ||= {} + const bk = u[t as keyof SignalDataSet]! + for (const i of Object.keys(bk)) { + const v = (bk as Record)[i] + if (v === null) delete data[t]![i] + else data[t]![i] = v + } + } + } + } + const creds = initAuthCreds() + const auth: SignalAuthState = { + creds, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + return { auth, creds, repository: makeLibSignalRepository(auth, logger), data } +} + +/** + * Rolling back to a pre-WASM release hands these rows to the JS libsignal, so + * the bytes written here have to be the shape it already stores: the 0x05 + * prefixed curve key, not the bare 32 bytes. + */ +describe('identity-key wire format', () => { + it('writes the 33-byte prefixed form the JS libsignal also stores', async () => { + const alice = mk() + const bob = mk() + const pk = initAuthCreds().signedPreKey.keyPair + await bob.auth.keys.set({ 'pre-key': { 1: pk } }) + await alice.repository.injectE2ESession({ + jid: '2222222222@s.whatsapp.net', + session: { + registrationId: bob.creds.registrationId, + identityKey: generateSignalPubKey(bob.creds.signedIdentityKey.public), + preKey: { keyId: 1, publicKey: generateSignalPubKey(pk.public) }, + signedPreKey: { + keyId: bob.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(bob.creds.signedPreKey.keyPair.public), + signature: bob.creds.signedPreKey.signature + } + } + }) + + const stored = alice.data['identity-key']!['2222222222.0'] as Uint8Array + expect(stored).toBeDefined() + expect(stored.length).toBe(33) + expect(stored[0]).toBe(5) + expect(Buffer.from(stored)).toEqual(Buffer.from(generateSignalPubKey(bob.creds.signedIdentityKey.public))) + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index c3754c9d876..56344685813 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -113,8 +113,10 @@ describe('snapshot session path', () => { const writes = alice.recorder.calls.filter(call => call.op === 'set') expect(writes).toHaveLength(1) - // Encrypt pins the peer identity alongside the session; both land together. - expect(writes[0]!.types.sort()).toEqual(['identity-key', 'session']) + // The peer identity is already known and unchanged, so only the session is + // written. Reporting it every time would rewrite that row once per device + // on every message sent. + expect(writes[0]!.types).toEqual(['session']) // Every read must precede the single write: a read after it would mean // the operation went back to storage mid-flight. @@ -122,6 +124,20 @@ describe('snapshot session path', () => { expect(alice.recorder.calls.slice(write + 1).some(call => call.op === 'get')).toBe(false) }) + it('does not rewrite the peer identity once it is known', async () => { + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + + alice.recorder.calls.length = 0 + for (let index = 0; index < 4; index++) { + await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`m${index}`) }) + } + + const identityWrites = alice.recorder.calls.filter(call => call.op === 'set' && call.types.includes('identity-key')) + expect(identityWrites).toHaveLength(0) + }) + it('deletes the consumed pre-key in the same write that stores the session', async () => { const alice = makeParty() const bob = makeParty() @@ -174,6 +190,43 @@ describe('snapshot session path', () => { expect(alice.recorder.calls.some(call => call.types.includes('pre-key'))).toBe(false) }) + it('stores the new session when the peer identity changes', async () => { + // A reinstalled peer sends a prekey message under a new identity. The + // store reports the old session as void AND the core builds a new one in + // the same operation, so the write must land the new session rather than + // the deletion. + const alice = makeParty() + const bob = makeParty() + const bobAgain = makeParty() + + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + const first = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('one') }) + await bob.repository.decryptMessage({ jid: aliceJid, ...first }) + + // bob reinstalls: same jid, brand new identity, and he opens a session to + // alice from scratch. + await bobAgain.repository.injectE2ESession({ jid: aliceJid, session: await bundleOf(alice, 9) }) + const fromNewBob = await bobAgain.repository.encryptMessage({ + jid: aliceJid, + data: Buffer.from('it is me again') + }) + + alice.recorder.calls.length = 0 + const plaintext = await alice.repository.decryptMessage({ jid: bobJid, ...fromNewBob }) + + expect(Buffer.from(plaintext).toString()).toBe('it is me again') + // The row must hold the new session, not be deleted. + expect(alice.recorder.data['session']?.['2222222222.0']).toBeDefined() + // ...and the identity is rewritten, since this one really did change. + const writes = alice.recorder.calls.filter(call => call.op === 'set') + expect(writes.some(call => call.types.includes('identity-key'))).toBe(true) + + // The session must still work afterwards. + const reply = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('welcome back') }) + const received = await bobAgain.repository.decryptMessage({ jid: aliceJid, ...reply }) + expect(Buffer.from(received).toString()).toBe('welcome back') + }) + it('leaves storage untouched when the operation fails', async () => { const alice = makeParty() const bob = makeParty() diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index eb044f14485..32c85bc089d 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -142,7 +142,13 @@ impl IdentityKeyStore for SnapshotStore { let bytes = identity.serialize().to_vec(); let mut inner = self.inner.borrow_mut(); let previous = inner.peer_identity.replace(bytes.clone()); - inner.changes.identity = Some(bytes.clone()); + + // The core calls this on every encrypt and decrypt to reassert trust on + // first use. Reporting a change when the key is the same would make the + // caller rewrite the identity row on every message, once per device. + if previous.as_deref() != Some(bytes.as_slice()) { + inner.changes.identity = Some(bytes.clone()); + } match previous { Some(old) if old != bytes => { From 251fcebc129c9746ea5af71cbec0c943dd88d884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 10:33:19 -0300 Subject: [PATCH 36/71] test(signal): generate the one-time pre-keys the fixtures use Borrowing a pair off a throwaway initAuthCreds() suggested a relationship to the peer that does not exist. A one-time pre-key is just a fresh pair, so make that explicit rather than reusing the peer's signed pre-key, which would put one key in two roles. --- .../baileys/src/__tests__/Signal/concurrent-session.test.ts | 4 ++-- .../baileys/src/__tests__/Signal/duplicate-decrypt.test.ts | 4 ++-- packages/baileys/src/__tests__/Signal/identity-format.test.ts | 4 ++-- .../baileys/src/__tests__/Signal/session-lost-update.test.ts | 4 ++-- .../baileys/src/__tests__/Signal/snapshot-session.test.ts | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts b/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts index ea7334017a2..96102368ed2 100644 --- a/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/concurrent-session.test.ts @@ -3,7 +3,7 @@ import P from 'pino' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' import { addTransactionCapability, initAuthCreds, makeCacheableSignalKeyStore } from '../../Utils/auth-utils' -import { generateSignalPubKey } from '../../Utils/crypto' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' const logger = P({ level: 'silent' }) @@ -50,7 +50,7 @@ const makeParty = (cacheable: boolean) => { } const bundleOf = async (party: ReturnType, preKeyId: number) => { - const preKey = initAuthCreds().signedPreKey.keyPair + const preKey = Curve.generateKeyPair() await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) return { diff --git a/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts index e8c96d3060b..6d19cecfd68 100644 --- a/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts +++ b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts @@ -3,7 +3,7 @@ import P from 'pino' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' -import { generateSignalPubKey } from '../../Utils/crypto' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' import { MISSING_KEYS_ERROR_TEXT } from '../../Utils/decode-wa-message' const logger = P({ level: 'silent' }) @@ -70,7 +70,7 @@ describe('decryptMessage duplicate handling', () => { // Alice opens a session towards Bob from Bob's published bundle. const bobPreKeyId = 1 - const preKeyPair = initAuthCreds().signedPreKey.keyPair + const preKeyPair = Curve.generateKeyPair() await bob.auth.keys.set({ 'pre-key': { [bobPreKeyId]: preKeyPair } }) await alice.repository.injectE2ESession({ diff --git a/packages/baileys/src/__tests__/Signal/identity-format.test.ts b/packages/baileys/src/__tests__/Signal/identity-format.test.ts index a0be9c241fa..c54733a1652 100644 --- a/packages/baileys/src/__tests__/Signal/identity-format.test.ts +++ b/packages/baileys/src/__tests__/Signal/identity-format.test.ts @@ -3,7 +3,7 @@ import P from 'pino' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' -import { generateSignalPubKey } from '../../Utils/crypto' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' const logger = P({ level: 'silent' }) const mk = () => { @@ -44,7 +44,7 @@ describe('identity-key wire format', () => { it('writes the 33-byte prefixed form the JS libsignal also stores', async () => { const alice = mk() const bob = mk() - const pk = initAuthCreds().signedPreKey.keyPair + const pk = Curve.generateKeyPair() await bob.auth.keys.set({ 'pre-key': { 1: pk } }) await alice.repository.injectE2ESession({ jid: '2222222222@s.whatsapp.net', diff --git a/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts b/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts index fb2a8dfafd5..ddaf0b5baa5 100644 --- a/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts +++ b/packages/baileys/src/__tests__/Signal/session-lost-update.test.ts @@ -9,7 +9,7 @@ import type { SignalKeyStoreWithTransaction } from '../../Types' import { addTransactionCapability, initAuthCreds, makeCacheableSignalKeyStore } from '../../Utils/auth-utils' -import { generateSignalPubKey } from '../../Utils/crypto' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' const logger = P({ level: 'silent' }) @@ -63,7 +63,7 @@ const makeParty = () => { } const bundleOf = async (party: ReturnType, preKeyId: number) => { - const preKey = initAuthCreds().signedPreKey.keyPair + const preKey = Curve.generateKeyPair() await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) return { diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index 56344685813..467cb16f7af 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -9,7 +9,7 @@ import type { SignalKeyStoreWithRecordTransaction } from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' -import { generateSignalPubKey } from '../../Utils/crypto' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' /** * The session path reads a snapshot, runs the protocol with no callbacks, and @@ -84,7 +84,7 @@ const makeParty = () => { } const bundleOf = async (party: ReturnType, preKeyId: number) => { - const preKey = initAuthCreds().signedPreKey.keyPair + const preKey = Curve.generateKeyPair() await party.auth.keys.set({ 'pre-key': { [preKeyId]: preKey } }) return { From fa510ed594547fc900da01f2f7d6bb14d0b0d15f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 10:51:24 -0300 Subject: [PATCH 37/71] fix(bridge): treat an absent JS property as absent Reflect::get answers Ok(undefined) for a property that is not there, so get_object never returned None. A legacy sender-key state written without senderMessageKeys, which is how the JS libsignal stored a state with none, then reached Array::from(undefined) and threw across the boundary, killing the group operation instead of migrating the row. The ok_or_else callers were dead for the same reason: a missing senderChainKey fell through as undefined instead of raising. --- .../src/storage_adapter.rs | 11 +++++- .../test/sender_key_migration.test.ts | 38 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 69f1f362265..f09e7161ca1 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -747,8 +747,17 @@ fn get_string(obj: &JsValue, key: &str) -> Option { .and_then(|v| v.as_string()) } +/// Reflect::get answers `Ok(undefined)` for a property that is not there, so +/// mapping it straight to Some() makes "absent" indistinguishable from "present". +/// Callers rely on None to default or to raise a clear error, and one of them fed +/// the undefined to `Array::from`, which throws across the boundary. fn get_object(obj: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok() + let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok()?; + if value.is_undefined() || value.is_null() { + return None; + } + + Some(value) } fn get_number(obj: &JsValue, key: &str) -> Option { diff --git a/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts b/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts index 3d09f4ac450..11f3df94e80 100644 --- a/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts +++ b/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts @@ -42,4 +42,42 @@ describe("Legacy SenderKey Migration", () => { expect(ciphertext).toBeDefined(); expect(ciphertext.length).toBeGreaterThan(0); }); + + /** + * The JS libsignal omitted senderMessageKeys entirely when a state had none, + * so a real upgraded auth state carries rows in this shape. + */ + it.each([ + ["omitted", {}], + ["null", { senderMessageKeys: null }], + ])("migrates a state whose senderMessageKeys is %s", async (_label, extra) => { + const storage = new FakeStorage(); + const groupId = "120363021033254949@g.us"; + const sender = new ProtocolAddress("236395184570386", 81); + + const legacySenderKey = [ + { + senderKeyId: 12345, + senderChainKey: { + iteration: 1, + seed: { type: "Buffer", data: Array.from(Buffer.alloc(32, 1)) }, + }, + senderSigningKey: { + public: { type: "Buffer", data: Array.from(Buffer.alloc(32, 2)) }, + private: { type: "Buffer", data: Array.from(Buffer.alloc(32, 3)) }, + }, + ...extra, + }, + ]; + + storage.senderKeys.set( + `${groupId}::${sender.id}::${sender.deviceId}`, + Buffer.from(JSON.stringify(legacySenderKey), "utf-8") + ); + + const cipher = new GroupCipher(storage, groupId, sender); + const ciphertext = await cipher.encrypt(new Uint8Array([1, 2, 3])); + + expect(ciphertext.length).toBeGreaterThan(0); + }); }); From 89dff6613489d40ff684e85c2bbc072bb3bdbae8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 10:51:24 -0300 Subject: [PATCH 38/71] ci: read the changed files without a pipe grep -q exits at the first match, so on a pull request large enough to fill the pipe the writer takes SIGPIPE and pipefail fails the condition, selecting the prebuilt on exactly the changes that need the source build. --- .github/actions/setup-workspace/action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml index d997a7d2980..58501686dea 100644 --- a/.github/actions/setup-workspace/action.yml +++ b/.github/actions/setup-workspace/action.yml @@ -73,7 +73,11 @@ runs: exit 0 fi - if printf '%s\n' "$files" | grep -qE '^packages/whatsapp-rust-bridge/'; then + # A here-string, not a pipe: grep -q exits at the first match, and a + # writer still filling the pipe would take SIGPIPE, which under pipefail + # fails the condition and quietly selects the prebuilt on exactly the + # large pull requests that most need the source build. + if grep -qE '^packages/whatsapp-rust-bridge/' <<< "$files"; then echo "changed=true" >> "$GITHUB_OUTPUT" echo "Bridge touched; building it from source." else From d82873fb9c2e4971f12c8e9027bb5c76d4b7deb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 10:56:15 -0300 Subject: [PATCH 39/71] test(bridge): unit-test the Rust side The changeset semantics and the JS property reads were only reachable through the TS suite, which exercises them end to end and cannot pin the edges: an unchanged identity reporting nothing, a replaced one voiding the session, a write the changeset cannot carry being refused, and an absent property reading as absent rather than as undefined. Runs under wasm-pack test --node, since the crate only builds for wasm32. The modules alias #[test] to wasm_bindgen_test to keep the annotations plain. --- .github/workflows/bridge-build.yml | 4 +- packages/whatsapp-rust-bridge/Cargo.lock | 128 ++++++++++++++++++ packages/whatsapp-rust-bridge/Cargo.toml | 7 + packages/whatsapp-rust-bridge/package.json | 3 +- .../src/snapshot_store.rs | 121 +++++++++++++++++ .../src/storage_adapter.rs | 80 +++++++++++ 6 files changed, 340 insertions(+), 3 deletions(-) diff --git a/.github/workflows/bridge-build.yml b/.github/workflows/bridge-build.yml index 72072dcba75..cd191730399 100644 --- a/.github/workflows/bridge-build.yml +++ b/.github/workflows/bridge-build.yml @@ -62,8 +62,8 @@ jobs: - name: Lint Rust run: cargo clippy --target wasm32-unknown-unknown --all-features -- -D warnings - - name: Run bridge typecheck and Jest tests - run: pnpm test:typecheck && pnpm test:jest + - name: Run bridge typecheck, Rust unit tests and Jest tests + run: pnpm test:typecheck && pnpm test:rust && pnpm test:jest - name: Test installed package, ESM/CommonJS, and WASM selection run: pnpm test:package diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index fde5b7f5dc2..85557c268dc 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -184,6 +184,12 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "castaway" version = "0.2.4" @@ -202,6 +208,16 @@ dependencies = [ "cipher", ] +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -464,6 +480,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.9" @@ -725,6 +747,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -753,6 +781,16 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -773,6 +811,15 @@ dependencies = [ "pxfm", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -780,6 +827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -788,6 +836,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -924,6 +978,15 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "semver" version = "1.0.28" @@ -1047,6 +1110,12 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" version = "0.3.9" @@ -1431,6 +1500,16 @@ dependencies = [ "waproto", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "waproto" version = "0.6.0" @@ -1518,6 +1597,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5ec93229ad9ccd0a545a516dec76dc276613f278f6a91aa6b463d5b33d42d0" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c81b9fef827e575e0e54431736d1baa0d700315d8c62cfef1f61fa3aad0cbeb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f4d8ae7ad5440360e9799dfd42857d126454a88441ddf72d288ef83fa47f527" + [[package]] name = "wasm-encoder" version = "0.244.0" @@ -1598,9 +1716,19 @@ dependencies = [ "waproto", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-bindgen-test", "web-sys", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/packages/whatsapp-rust-bridge/Cargo.toml b/packages/whatsapp-rust-bridge/Cargo.toml index d7ffd929c2a..430b284d4bd 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -107,6 +107,13 @@ web-sys = { version = "0.3", features = [ "ReadableStreamDefaultReader", ] } +# Runs the crate's `#[test]` unit tests on wasm32 in Node via +# `wasm-pack test --node` — plain `cargo test` cannot, because the crate only +# builds for wasm32 (no native test harness). The test modules alias +# `#[test]` -> `#[wasm_bindgen_test]`, so there is no per-test annotation churn. +[dev-dependencies] +wasm-bindgen-test = "0.3" + [profile.release] lto = "fat" opt-level = 3 diff --git a/packages/whatsapp-rust-bridge/package.json b/packages/whatsapp-rust-bridge/package.json index 1ea7fba81ee..4a16587721d 100644 --- a/packages/whatsapp-rust-bridge/package.json +++ b/packages/whatsapp-rust-bridge/package.json @@ -41,10 +41,11 @@ "postbuild": "tsc -p tsconfig.json --outDir dist", "build": "pnpm run prebuild && pnpm run build:wasm && pnpm run build:ts && pnpm run postbuild", "test:typecheck": "tsc -p tsconfig.test.json --noEmit", + "test:rust": "wasm-pack test --node", "test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs --runInBand", "test:modules": "node --test test/module-formats.test.mjs test/module-formats.test.cjs", "test:package": "pnpm run test:modules && node scripts/check-package.mjs", - "test": "pnpm run test:typecheck && pnpm run test:jest && pnpm run test:package", + "test": "pnpm run test:typecheck && pnpm run test:rust && pnpm run test:jest && pnpm run test:package", "prepublishOnly": "pnpm run build" }, "devDependencies": { diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index 32c85bc089d..494dad841ef 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -266,3 +266,124 @@ impl SenderKeyStore for SnapshotStore { Ok(self.inner.borrow().sender_key.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + // Alias `#[test]` -> wasm_bindgen_test so these run on wasm32 via + // `wasm-pack test --node` (the crate has no native test target). + use wasm_bindgen_test::wasm_bindgen_test as test; + + use wacore_libsignal::protocol::{IdentityKeyPair, KeyPair}; + + fn store() -> SnapshotStore { + let mut rng = rand::make_rng::(); + SnapshotStore::new(IdentityKeyPair::generate(&mut rng), 42) + } + + fn some_identity() -> IdentityKey { + let mut rng = rand::make_rng::(); + IdentityKey::new(KeyPair::generate(&mut rng).public_key) + } + + fn address() -> ProtocolAddress { + ProtocolAddress::new("alice", 1u32.into()) + } + + #[test] + async fn reports_nothing_when_the_operation_touched_nothing() { + let changes = store().take_changes(); + + assert!(changes.session.is_none()); + assert!(changes.identity.is_none()); + assert!(changes.removed_pre_key.is_none()); + assert!(changes.sender_key.is_none()); + assert!(!changes.session_cleared); + } + + #[test] + async fn reports_a_first_identity() { + let mut store = store(); + let identity = some_identity(); + + let change = store.save_identity(&address(), &identity).await.unwrap(); + + assert!(matches!(change, IdentityChange::NewOrUnchanged)); + assert_eq!( + store.take_changes().identity.as_deref(), + Some(identity.serialize().as_ref()) + ); + } + + #[test] + async fn stays_silent_when_the_identity_is_unchanged() { + let mut store = store(); + let identity = some_identity(); + + store.save_identity(&address(), &identity).await.unwrap(); + store.take_changes(); + // The core reasserts trust on every operation; repeating the same key + // must not make the caller rewrite the row. + let change = store.save_identity(&address(), &identity).await.unwrap(); + + assert!(matches!(change, IdentityChange::NewOrUnchanged)); + assert!(store.take_changes().identity.is_none()); + } + + #[test] + async fn voids_the_session_when_the_identity_is_replaced() { + let mut store = store(); + store + .save_identity(&address(), &some_identity()) + .await + .unwrap(); + store.take_changes(); + + let replacement = some_identity(); + let change = store.save_identity(&address(), &replacement).await.unwrap(); + + assert!(matches!(change, IdentityChange::ReplacedExisting)); + let changes = store.take_changes(); + assert!(changes.session_cleared); + // The caller must delete the row, not write a session built on the old key. + assert!(changes.session.is_none()); + assert_eq!( + changes.identity.as_deref(), + Some(replacement.serialize().as_ref()) + ); + } + + #[test] + async fn reports_a_removed_pre_key_without_applying_it() { + let mut store = store(); + + store.remove_pre_key(PreKeyId::from(7u32)).await.unwrap(); + + assert_eq!(store.take_changes().removed_pre_key, Some(7)); + } + + #[test] + async fn refuses_writes_it_cannot_report() { + let mut store = store(); + let mut rng = rand::make_rng::(); + let record = PreKeyRecord::new(PreKeyId::from(1u32), &KeyPair::generate(&mut rng)); + + // Accepting these silently would drop the write when the operation + // returns, since the changeset has nowhere to carry it. + assert!( + store + .save_pre_key(PreKeyId::from(1u32), &record) + .await + .is_err() + ); + } + + #[test] + async fn take_changes_drains() { + let mut store = store(); + store.remove_pre_key(PreKeyId::from(3u32)).await.unwrap(); + + assert!(store.take_changes().removed_pre_key.is_some()); + assert!(store.take_changes().removed_pre_key.is_none()); + } +} diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index f09e7161ca1..14a8b92f8a0 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1161,3 +1161,83 @@ impl SenderKeyStore for JsStorageAdapter { Ok(()) } } + +#[cfg(test)] +mod js_value_tests { + use super::{get_bytes_from_buffer_json, get_number, get_object}; + use wasm_bindgen::JsValue; + // Alias `#[test]` -> wasm_bindgen_test so these run on wasm32 via + // `wasm-pack test --node` (the crate has no native test target). + use wasm_bindgen_test::wasm_bindgen_test as test; + + fn object_with(key: &str, value: JsValue) -> JsValue { + let obj = js_sys::Object::new(); + js_sys::Reflect::set(&obj, &JsValue::from_str(key), &value).unwrap(); + obj.into() + } + + #[test] + fn absent_property_reads_as_absent() { + // Reflect::get answers Ok(undefined) here. Reporting that as present is + // what fed undefined to Array::from and threw across the boundary on a + // legacy sender-key state stored without senderMessageKeys. + let obj: JsValue = js_sys::Object::new().into(); + + assert!(get_object(&obj, "senderMessageKeys").is_none()); + } + + #[test] + fn explicit_undefined_and_null_read_as_absent() { + assert!(get_object(&object_with("k", JsValue::UNDEFINED), "k").is_none()); + assert!(get_object(&object_with("k", JsValue::NULL), "k").is_none()); + } + + #[test] + fn a_present_value_is_returned() { + let value = object_with("k", JsValue::from_str("v")); + + assert_eq!( + get_object(&value, "k").and_then(|v| v.as_string()), + Some("v".to_owned()) + ); + } + + #[test] + fn an_empty_array_is_present() { + // Distinct from absent: the state has no message keys, which is a value. + let value = object_with("k", js_sys::Array::new().into()); + + assert!(get_object(&value, "k").is_some()); + } + + #[test] + fn a_number_is_read_or_defaulted() { + let value = object_with("iteration", JsValue::from_f64(7.0)); + + assert_eq!(get_number(&value, "iteration"), Some(7.0)); + assert_eq!(get_number(&value, "missing"), None); + } + + #[test] + fn a_buffer_json_envelope_decodes_to_bytes() { + // The JS libsignal serialised Buffers as { type: 'Buffer', data: [...] }. + let data = js_sys::Array::new(); + for byte in [1u8, 2, 3] { + data.push(&JsValue::from_f64(byte as f64)); + } + + let envelope = js_sys::Object::new(); + js_sys::Reflect::set(&envelope, &JsValue::from_str("type"), &"Buffer".into()).unwrap(); + js_sys::Reflect::set(&envelope, &JsValue::from_str("data"), &data).unwrap(); + let holder = object_with("seed", envelope.into()); + + assert_eq!( + get_bytes_from_buffer_json(&holder, "seed").unwrap(), + Some(vec![1, 2, 3]) + ); + assert_eq!( + get_bytes_from_buffer_json(&holder, "missing").unwrap(), + None + ); + } +} From 632367af2db2dd64d51b815fb3018856f9d4eda3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 11:08:28 -0300 Subject: [PATCH 40/71] test(bridge): skip the Rust tests when wasm-pack is absent AGENTS.md tells contributors they do not need the Rust toolchain, since the postinstall pulls the prebuilt. Putting test:rust in the default chain broke `pnpm test` for exactly those checkouts with "wasm-pack: command not found". The gate is an if/else rather than `&& ... ||`, so a failing Rust test still fails the run instead of falling through to the skip message. CI keeps calling test:rust directly, where the toolchain is always installed. --- AGENTS.md | 5 +++++ packages/whatsapp-rust-bridge/package.json | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index dc223d3282a..73cfcef6170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,11 @@ If you are working on the Rust crate: # Skip the prebuilt fetch and use your local build instead. WHATSAPP_RUST_BRIDGE_SKIP_PREBUILT=1 pnpm install pnpm --filter whatsapp-rust-bridge build # needs cargo, wasm-pack, and wasm-opt + +# Rust unit tests. The crate only builds for wasm32, so they run in Node +# through wasm-pack rather than `cargo test`. `pnpm test` skips them when +# wasm-pack is absent, so a no-Rust checkout still passes; CI always runs them. +pnpm --filter whatsapp-rust-bridge test:rust ``` A new bridge release goes out by tagging `whatsapp-rust-bridge@` — the `bridge-release.yml` workflow builds, publishes to npm, and opens a follow-up PR refreshing `dist.sha256`. diff --git a/packages/whatsapp-rust-bridge/package.json b/packages/whatsapp-rust-bridge/package.json index 4a16587721d..31a0d409e35 100644 --- a/packages/whatsapp-rust-bridge/package.json +++ b/packages/whatsapp-rust-bridge/package.json @@ -42,10 +42,11 @@ "build": "pnpm run prebuild && pnpm run build:wasm && pnpm run build:ts && pnpm run postbuild", "test:typecheck": "tsc -p tsconfig.test.json --noEmit", "test:rust": "wasm-pack test --node", + "test:rust:if-available": "if command -v wasm-pack > /dev/null 2>&1; then pnpm run test:rust; else echo '[whatsapp-rust-bridge] wasm-pack not installed, skipping Rust unit tests (run pnpm test:rust after installing it).'; fi", "test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs --runInBand", "test:modules": "node --test test/module-formats.test.mjs test/module-formats.test.cjs", "test:package": "pnpm run test:modules && node scripts/check-package.mjs", - "test": "pnpm run test:typecheck && pnpm run test:rust && pnpm run test:jest && pnpm run test:package", + "test": "pnpm run test:typecheck && pnpm run test:rust:if-available && pnpm run test:jest && pnpm run test:package", "prepublishOnly": "pnpm run build" }, "devDependencies": { From bfb489e2925692565a968215af6aba1a2d6d92af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 11:15:33 -0300 Subject: [PATCH 41/71] test(bridge): detect wasm-pack from Node instead of the shell The previous gate used `command -v` inside an if/then/fi, which is POSIX only: on Windows pnpm runs scripts through cmd.exe, and PowerShell rejects it with a parse error, so `pnpm test` was broken for those contributors instead of skipping. The lookup now walks PATH in Node, applying PATHEXT on Windows, and spawns the binary directly. An npm-installed wasm-pack is a .cmd, which Node will not spawn without a shell, so that case passes one quoted command string. --- AGENTS.md | 3 +- packages/whatsapp-rust-bridge/package.json | 2 +- .../scripts/test-rust.mjs | 63 +++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 packages/whatsapp-rust-bridge/scripts/test-rust.mjs diff --git a/AGENTS.md b/AGENTS.md index 73cfcef6170..d991c6a2764 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,7 +54,8 @@ pnpm --filter whatsapp-rust-bridge build # needs cargo, wasm-pack, and wasm-op # Rust unit tests. The crate only builds for wasm32, so they run in Node # through wasm-pack rather than `cargo test`. `pnpm test` skips them when -# wasm-pack is absent, so a no-Rust checkout still passes; CI always runs them. +# wasm-pack is absent, on any platform, so a no-Rust checkout still passes; +# CI always runs them. pnpm --filter whatsapp-rust-bridge test:rust ``` diff --git a/packages/whatsapp-rust-bridge/package.json b/packages/whatsapp-rust-bridge/package.json index 31a0d409e35..2a3906396e0 100644 --- a/packages/whatsapp-rust-bridge/package.json +++ b/packages/whatsapp-rust-bridge/package.json @@ -42,7 +42,7 @@ "build": "pnpm run prebuild && pnpm run build:wasm && pnpm run build:ts && pnpm run postbuild", "test:typecheck": "tsc -p tsconfig.test.json --noEmit", "test:rust": "wasm-pack test --node", - "test:rust:if-available": "if command -v wasm-pack > /dev/null 2>&1; then pnpm run test:rust; else echo '[whatsapp-rust-bridge] wasm-pack not installed, skipping Rust unit tests (run pnpm test:rust after installing it).'; fi", + "test:rust:if-available": "node scripts/test-rust.mjs", "test:jest": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config jest.config.mjs --runInBand", "test:modules": "node --test test/module-formats.test.mjs test/module-formats.test.cjs", "test:package": "pnpm run test:modules && node scripts/check-package.mjs", diff --git a/packages/whatsapp-rust-bridge/scripts/test-rust.mjs b/packages/whatsapp-rust-bridge/scripts/test-rust.mjs new file mode 100644 index 00000000000..67df635f517 --- /dev/null +++ b/packages/whatsapp-rust-bridge/scripts/test-rust.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Runs the crate's Rust unit tests, or explains why it did not. +// +// AGENTS.md tells contributors they do not need the Rust toolchain: the +// postinstall pulls a prebuilt bridge. So `pnpm test` must not hard-fail on a +// checkout without wasm-pack. CI calls `test:rust` directly, where the +// toolchain is always present. +// +// The lookup walks PATH itself rather than shelling out: `command -v` is POSIX +// only and would be a parse error in cmd.exe and PowerShell, and spawning +// without a shell on Windows does not apply PATHEXT. +import { spawnSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { delimiter, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +const executableExtensions = + process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''] + +function locate(command) { + for (const directory of (process.env.PATH ?? '').split(delimiter)) { + if (!directory) continue + + for (const extension of executableExtensions) { + const candidate = join(directory, command + extension) + if (existsSync(candidate)) { + return candidate + } + } + } + + return undefined +} + +const wasmPack = locate('wasm-pack') +if (!wasmPack) { + console.log( + '[whatsapp-rust-bridge] wasm-pack not found, skipping the Rust unit tests. ' + + 'Install it and run `pnpm test:rust` if you are working on the crate.' + ) + process.exit(0) +} + +// Node refuses to spawn .cmd/.bat without a shell, and an npm-installed +// wasm-pack on Windows is a .cmd. In that case pass one command string with the +// path quoted (it may sit under "Program Files"); passing an argv array +// alongside shell:true is deprecated because the parts are only concatenated. +const needsShell = /\.(cmd|bat)$/i.test(wasmPack) +const spawned = needsShell + ? spawnSync(`"${wasmPack}" test --node`, { stdio: 'inherit', cwd: root, shell: true }) + : spawnSync(wasmPack, ['test', '--node'], { stdio: 'inherit', cwd: root }) + +const { status, error } = spawned +if (error) { + console.error(`[whatsapp-rust-bridge] could not run ${wasmPack}: ${error.message}`) + process.exit(1) +} + +// Propagate the failure: a broken Rust test has to fail the run, not fall +// through as if the toolchain were missing. +process.exit(status ?? 1) From 20e69e88f4ed0de03e16c10cfb9e3834cbda4dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 11:25:46 -0300 Subject: [PATCH 42/71] fix: review follow-ups after the develop merge - build.yml checks out without credentials, matching the other workflows - toTypedRecord skips a hole in _sessions instead of casting undefined, which would throw and lose the live sessions alongside the broken entry - the legacy Buffer envelope is typed as number[] | string and decoded on the runtime type rather than assuming base64 - the plain-address device-99 test asserts the destination holds the bytes - the lock test records event order instead of asserting on a delay, which on a slow machine would also hold when the encrypt simply had not run yet - the sender-key shapes share one table identity-format keeps handing the bundle a prefixed key: the bridge normalises the identity inside a snapshot but decodes bundle.identityKey strictly, so a bare 32 bytes is rejected there. --- .github/workflows/build.yml | 2 + .../src/Signal/legacy-session-codec.ts | 13 +++-- .../__tests__/Signal/identity-format.test.ts | 3 ++ .../src/__tests__/Signal/legacy-codec.test.ts | 5 +- .../__tests__/Signal/legacy-session.test.ts | 3 ++ .../__tests__/Signal/snapshot-session.test.ts | 29 ++++++------ .../test/sender_key_migration.test.ts | 47 ++----------------- 7 files changed, 41 insertions(+), 61 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 10116a0ce3f..e4b9095cf9c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -17,6 +17,8 @@ jobs: steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Setup workspace uses: ./.github/actions/setup-workspace diff --git a/packages/baileys/src/Signal/legacy-session-codec.ts b/packages/baileys/src/Signal/legacy-session-codec.ts index 02d6cdc1ca8..d5a4deef9b4 100644 --- a/packages/baileys/src/Signal/legacy-session-codec.ts +++ b/packages/baileys/src/Signal/legacy-session-codec.ts @@ -115,10 +115,15 @@ const toTypedSession = (entry: LegacyEntryJson): LegacySessionV1 => { /** Legacy on-disk JSON → the bridge's typed model. */ export const toTypedRecord = (record: LegacySessionRecord): LegacySessionRecordV1 => { - const sessions = Object.entries(record._sessions || {}).map(([indexKey, entry]) => ({ - indexKey: decode(indexKey, 'session index key'), - session: toTypedSession(entry as LegacyEntryJson) - })) + // A hole in the record is skipped rather than cast: converting undefined + // would throw and take the whole record with it, losing the live sessions + // alongside the broken entry. + const sessions = Object.entries(record._sessions || {}) + .filter((pair): pair is [string, LegacyEntryJson] => pair[1] !== undefined && pair[1] !== null) + .map(([indexKey, entry]) => ({ + indexKey: decode(indexKey, 'session index key'), + session: toTypedSession(entry) + })) return { sessions } } diff --git a/packages/baileys/src/__tests__/Signal/identity-format.test.ts b/packages/baileys/src/__tests__/Signal/identity-format.test.ts index c54733a1652..a28f79950cb 100644 --- a/packages/baileys/src/__tests__/Signal/identity-format.test.ts +++ b/packages/baileys/src/__tests__/Signal/identity-format.test.ts @@ -50,6 +50,9 @@ describe('identity-key wire format', () => { jid: '2222222222@s.whatsapp.net', session: { registrationId: bob.creds.registrationId, + // Prefixed, as the wire format and every caller supply it. The bridge + // normalises the identity inside a snapshot but decodes the bundle's + // key strictly, so a bare 32 bytes is rejected here. identityKey: generateSignalPubKey(bob.creds.signedIdentityKey.public), preKey: { keyId: 1, publicKey: generateSignalPubKey(pk.public) }, signedPreKey: { diff --git a/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts index 558c8ba5eff..a9898d048e9 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts @@ -12,7 +12,10 @@ const logger = P({ level: 'silent' }) const revive = (value: unknown): unknown => { if (typeof value === 'object' && value !== null && (value as { type?: string }).type === 'Buffer') { - return Buffer.from((value as { data: string }).data, 'base64') + // JSON.stringify writes a Buffer as { type: 'Buffer', data: number[] }; + // fixtures captured by hand may carry base64 instead. + const { data } = value as { data: number[] | string } + return typeof data === 'string' ? Buffer.from(data, 'base64') : Buffer.from(data) } if (Array.isArray(value)) return value.map(revive) diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 825f887e007..715da65c9d7 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -312,6 +312,9 @@ describe('repository on a pre-WASM auth state', () => { const result = await repository.migrateSession(pnJid, lidJid) expect(result.migrated).toBe(1) + expect(Buffer.from(data.session![`18000000000004_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( + Buffer.from(pnBytes) + ) expect(data.session![plainAddr]).toBeUndefined() }) diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index 467cb16f7af..fd87bc42919 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -260,27 +260,28 @@ describe('snapshot session path', () => { const keys = alice.auth.keys as SignalKeyStoreWithRecordTransaction const wireJid = '2222222222.0' - let encryptFinished = false + // Recorded rather than timed: asserting "not finished yet" after a delay + // would also hold on a slow machine where the encrypt simply had not got + // there, so the order is what proves it waited. + const order: string[] = [] const release = deferred() // Held from a scope of its own: calling encrypt inside the callback would // nest it, and a nested scope does not contend with its parent. - const holding = keys.transactWith({ records: [{ type: 'identity-key', id: wireJid }] }, async () => { - await release.promise - }) - await tick() + const holding = keys + .transactWith({ records: [{ type: 'identity-key', id: wireJid }] }, () => release.promise) + .then(() => order.push('lock released')) - const encrypting = alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('blocked') }).then(() => { - encryptFinished = true - }) + await tick() + const encrypting = alice.repository + .encryptMessage({ jid: bobJid, data: Buffer.from('blocked') }) + .then(() => order.push('encrypt finished')) + // Give an unblocked encrypt every chance to land before the lock goes. await Promise.race([encrypting, tick()]) - expect(encryptFinished).toBe(false) - release.resolve() - await holding - await encrypting - await holding - expect(encryptFinished).toBe(true) + await Promise.all([holding, encrypting]) + + expect(order).toEqual(['lock released', 'encrypt finished']) }) it('keeps the chain monotonic when encrypts overlap on one session', async () => { diff --git a/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts b/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts index 11f3df94e80..ef3db45e6cd 100644 --- a/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts +++ b/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts @@ -3,54 +3,17 @@ import { ProtocolAddress, GroupCipher } from "../dist/index.js"; import { FakeStorage } from "./helpers/fake_storage"; describe("Legacy SenderKey Migration", () => { - it("should migrate a legacy JSON sender key into a valid record", async () => { - const storage = new FakeStorage(); - const groupId = "120363021033254949@g.us"; - const sender = new ProtocolAddress("236395184570386", 81); - - // Legacy JSON structure (as provided by user) - // It's an array of SenderKeyStateStructure - const legacySenderKey = [ - { - senderKeyId: 12345, - senderChainKey: { - iteration: 1, - seed: { type: "Buffer", data: Array.from(Buffer.alloc(32, 1)) }, - }, - senderSigningKey: { - public: { type: "Buffer", data: Array.from(Buffer.alloc(32, 2)) }, - private: { type: "Buffer", data: Array.from(Buffer.alloc(32, 3)) }, - }, - senderMessageKeys: [], - }, - ]; - - const legacyJson = JSON.stringify(legacySenderKey); - const legacyBytes = Buffer.from(legacyJson, "utf-8"); - - // Store it in the fake storage - storage.senderKeys.set( - `${groupId}::${sender.id}::${sender.deviceId}`, - legacyBytes - ); - - const cipher = new GroupCipher(storage, groupId, sender); - - const plaintext = new Uint8Array([1, 2, 3]); - const ciphertext = await cipher.encrypt(plaintext); - - expect(ciphertext).toBeDefined(); - expect(ciphertext.length).toBeGreaterThan(0); - }); - /** * The JS libsignal omitted senderMessageKeys entirely when a state had none, * so a real upgraded auth state carries rows in this shape. */ - it.each([ + const shapes = [ + ["present but empty", { senderMessageKeys: [] }], ["omitted", {}], ["null", { senderMessageKeys: null }], - ])("migrates a state whose senderMessageKeys is %s", async (_label, extra) => { + ] as const satisfies readonly (readonly [string, Record])[]; + + it.each(shapes)("migrates a state whose senderMessageKeys is %s", async (_label, extra) => { const storage = new FakeStorage(); const groupId = "120363021033254949@g.us"; const sender = new ProtocolAddress("236395184570386", 81); From bd9e2c5c0d07736fc4dc68636b4cd48e147d36ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 12:00:32 -0300 Subject: [PATCH 43/71] fix(signal): build a sender key without seeding an empty record getSenderKeyDistributionMessage stored an empty SenderKeyRecord before asking the builder to create one. The builder handles a missing row by creating a record, but a stored empty one loads as a state-less session and the create then fails with InvalidSenderKeySession, so distributing your own sender key was broken and no group could be sent to. The JS backend passed the record object rather than its bytes and did not have that distinction. Nothing covered getSenderKeyDistributionMessage, which is why it went unnoticed; group-sender-key.test.ts now does. rollback-step1 becomes a test instead of a script that never ran. It pins what a rollback can and cannot carry: a session this backend advanced projects back to the legacy shape and survives the round trip, while a sender key written here cannot be parsed by the old build and has to be redistributed. --- packages/baileys/src/Signal/libsignal.ts | 11 +- .../__tests__/Signal/group-sender-key.test.ts | 123 ++++++++++++++++ .../src/__tests__/Signal/rollback.test.ts | 137 ++++++++++++++++++ .../src/__tests__/fixtures/rollback-step1.ts | 117 --------------- 4 files changed, 265 insertions(+), 123 deletions(-) create mode 100644 packages/baileys/src/__tests__/Signal/group-sender-key.test.ts create mode 100644 packages/baileys/src/__tests__/Signal/rollback.test.ts delete mode 100644 packages/baileys/src/__tests__/fixtures/rollback-step1.ts diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 6fa072078a0..c815d009024 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -161,12 +161,11 @@ export function makeLibSignalRepository( const ensureSenderKeyAndCreateSkdm = async (group: string, meId: string) => { const senderName = jidToSignalSenderKeyName(group, meId) - const senderNameStr = senderName.toString() - const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr]) - if (!senderKey) { - await storage.storeSenderKey(senderNameStr, new SenderKeyRecord().serialize()) - } - + // Do not seed an empty record first: the builder creates one when the row + // is absent, but a stored empty record loads as a state-less session and + // the create then fails with InvalidSenderKeySession. The JS backend this + // replaced took the record object, not its bytes, and did not have that + // distinction. const skdm = await new GroupSessionBuilder(storage).create(senderName) return { senderName, skdm } } diff --git a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts new file mode 100644 index 00000000000..25fb41bbe43 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' + +/** + * Sending into a group starts with distributing your own sender key. Nothing + * covered that, and seeding an empty record before building one made it fail + * with InvalidSenderKeySession, which would have left every group unusable for + * sending. + */ +const logger = P({ level: 'silent' }) + +const makeParty = () => { + const data: { [type: string]: { [id: string]: unknown } } = {} + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) out[id] = bucket[id] as never + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } + + const auth: SignalAuthState = { + creds: initAuthCreds(), + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + + return { auth, data, repository: makeLibSignalRepository(auth, logger) } +} + +const groupJid = '120363000000000001@g.us' +const aliceJid = '5511900000001@s.whatsapp.net' +const bobJid = '5511900000002@s.whatsapp.net' + +describe('group sender keys', () => { + it('builds a distribution message on a store that has none', async () => { + const alice = makeParty() + + const skdm = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + + expect(skdm.length).toBeGreaterThan(0) + expect(await alice.repository.hasSenderKey({ group: groupJid, meId: aliceJid })).toBe(true) + }) + + it('returns the same sender key on a second call', async () => { + const alice = makeParty() + + const first = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + const second = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + + // A fresh key each time would strand every peer that adopted the first. + expect(Buffer.from(second).toString('base64')).toBe(Buffer.from(first).toString('base64')) + }) + + it('carries a message from the distributor to a peer', async () => { + const alice = makeParty() + const bob = makeParty() + + const skdm = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + await bob.repository.processSenderKeyDistributionMessage({ + authorJid: aliceJid, + item: { groupId: groupJid, axolotlSenderKeyDistributionMessage: skdm } as never + }) + + const sent = await alice.repository.encryptGroupMessage({ + group: groupJid, + meId: aliceJid, + data: Buffer.from('hello group') + }) + const received = await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: sent.ciphertext + }) + + expect(Buffer.from(received).toString()).toBe('hello group') + }) + + it('lets two members each distribute and send', async () => { + const alice = makeParty() + const bob = makeParty() + + for (const [from, jid, other] of [ + [alice, aliceJid, bob], + [bob, bobJid, alice] + ] as const) { + const skdm = await from.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: jid }) + await other.repository.processSenderKeyDistributionMessage({ + authorJid: jid, + item: { groupId: groupJid, axolotlSenderKeyDistributionMessage: skdm } as never + }) + + const sent = await from.repository.encryptGroupMessage({ + group: groupJid, + meId: jid, + data: Buffer.from(`from ${jid}`) + }) + const received = await other.repository.decryptGroupMessage({ + group: groupJid, + authorJid: jid, + msg: sent.ciphertext + }) + expect(Buffer.from(received).toString()).toBe(`from ${jid}`) + } + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts new file mode 100644 index 00000000000..b6b58d039b0 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' +import { fromTypedRecord, toTypedRecord } from '../../Signal/legacy-session-codec' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability } from '../../Utils/auth-utils' +import fixture from '../fixtures/legacy-session-rc9.json' + +/** + * Going back to a pre-WASM release. Bob upgrades carrying an rc.9 auth state, + * uses it, and then changes his mind: the session he is now on has to be + * expressible in the legacy JSON shape the JS libsignal reads. + * + * Sender keys have no such projection. A group key written by this backend + * cannot be read by the old one, so that limitation is asserted here rather + * than left to be discovered during a rollback. + */ +const logger = P({ level: 'silent' }) + +const revive = (value: unknown): unknown => { + if (typeof value === 'object' && value !== null && (value as { type?: string }).type === 'Buffer') { + const { data } = value as { data: number[] | string } + return typeof data === 'string' ? Buffer.from(data, 'base64') : Buffer.from(data) + } + + if (Array.isArray(value)) return value.map(revive) + if (ArrayBuffer.isView(value)) return value + if (typeof value === 'object' && value !== null) { + return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)])) + } + + return value +} + +const makeBob = () => { + const data = revive(fixture.bob.store) as Record> + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) out[id] = bucket[id] as never + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } + + const auth: SignalAuthState = { + creds: revive(fixture.bob.creds) as never, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + + return { data, repository: makeLibSignalRepository(auth, logger) } +} + +const { aliceJid, groupJid } = fixture.jids +const sessionAddr = '5511900000001.0' + +describe('rolling back to a pre-WASM release', () => { + it('projects a session this backend advanced into the legacy shape', async () => { + const bob = makeBob() + + // Use the upgraded state the way a running client would. + for (const message of fixture.pending) { + await bob.repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: Buffer.from(message.ct, 'base64') + }) + } + + await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('from-new-bob') }) + + const projection = projectLegacySessionRecordV1(bob.data.session![sessionAddr] as Uint8Array) + // Narrowed by hand: expect() does not tell the compiler which arm this is. + if (projection.status !== 'projected') { + throw new Error(`not projectable: ${JSON.stringify(projection.issue)}`) + } + + const legacy = fromTypedRecord(projection.record) + // The shape the JS libsignal expects: a record keyed by base64 index keys. + const sessions = legacy._sessions ?? {} + expect(Object.keys(sessions).length).toBeGreaterThan(0) + for (const entry of Object.values(sessions)) { + expect(entry).toHaveProperty('currentRatchet') + expect(entry).toHaveProperty('indexInfo') + } + }) + + it('round-trips the projection back through the bridge unchanged', async () => { + const bob = makeBob() + await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('one more') }) + + const bytes = bob.data.session![sessionAddr] as Uint8Array + const projection = projectLegacySessionRecordV1(bytes) + if (projection.status !== 'projected') { + throw new Error(`not projectable: ${JSON.stringify(projection.issue)}`) + } + + // Legacy JSON -> typed model -> legacy JSON must be stable, or a rollback + // would hand the old build a record it wrote differently than it reads. + const legacy = fromTypedRecord(projection.record) + const again = fromTypedRecord(toTypedRecord(legacy)) + + expect(JSON.stringify(again)).toBe(JSON.stringify(legacy)) + }) + + it('leaves a group sender key in a shape the old build cannot read', async () => { + const bob = makeBob() + + // Reading the legacy row is fine; writing is what changes the shape. + await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: Buffer.from(fixture.pendingGroup[0]!.ct, 'base64') + }) + + const stored = bob.data['sender-key']![`${groupJid}::5511900000001::0`] as Uint8Array + // The JS backend parses this row as JSON. Once this backend has written + // it, that parse fails: there is no projection for sender keys, so a + // rollback needs the group keys to be redistributed. + expect(() => JSON.parse(Buffer.from(stored).toString())).toThrow() + }) +}) diff --git a/packages/baileys/src/__tests__/fixtures/rollback-step1.ts b/packages/baileys/src/__tests__/fixtures/rollback-step1.ts deleted file mode 100644 index 33d058ae030..00000000000 --- a/packages/baileys/src/__tests__/fixtures/rollback-step1.ts +++ /dev/null @@ -1,117 +0,0 @@ -// Step 1 of the rollback proof, run with tsx inside packages/baileys. -// -// Bob upgrades to this branch carrying an rc.9 auth state: he consumes the -// ciphertexts the old build left pending, sends a new message, and then decides -// to go back — so his session is projected into the legacy JSON shape again. -// Step 2 feeds that projection to the real rc.9 and keeps the conversation going. -import { writeFileSync } from 'node:fs' -import P from 'pino' -import { projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' -import { fromTypedRecord } from '../../Signal/legacy-session-codec' -import { makeLibSignalRepository } from '../../Signal/libsignal' -import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' -import { addTransactionCapability } from '../../Utils/auth-utils' -import fixture from './legacy-session-rc9.json' - -const logger = P({ level: 'silent' }) - -const revive = (value: unknown): unknown => { - if (typeof value === 'object' && value !== null && (value as { type?: string }).type === 'Buffer') { - return Buffer.from((value as { data: string }).data, 'base64') - } - - if (Array.isArray(value)) return value.map(revive) - if (ArrayBuffer.isView(value)) return value - if (typeof value === 'object' && value !== null) { - return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, revive(v)])) - } - - return value -} - -const data = revive(fixture.bob.store) as Record> -const store: SignalKeyStore = { - get: async (type, ids) => { - const bucket = data[type] || {} - const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} - for (const id of ids) { - if (bucket[id] !== undefined && bucket[id] !== null) { - out[id] = bucket[id] as SignalDataTypeMap[typeof type] - } - } - - return out - }, - set: async (update: SignalDataSet) => { - for (const type of Object.keys(update)) { - data[type] ||= {} - const bucket = update[type as keyof SignalDataSet]! - for (const id of Object.keys(bucket)) { - const value = (bucket as Record)[id] - if (value === null) delete data[type]![id] - else data[type]![id] = value - } - } - } -} - -const auth: SignalAuthState = { - creds: revive(fixture.bob.creds) as never, - keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) -} -const repository = makeLibSignalRepository(auth, logger) - -const { aliceJid, groupJid } = fixture.jids -const sessionAddr = '5511900000001.0' - -const main = async () => { - // 1. Consume what the old build enciphered but never delivered. - for (const message of fixture.pending) { - const plaintext = await repository.decryptMessage({ - jid: aliceJid, - type: message.type as 'msg' | 'pkmsg', - ciphertext: Buffer.from(message.ct, 'base64') - }) - if (Buffer.from(plaintext).toString() !== message.pt) throw new Error(`DM mismatch: ${message.pt}`) - } - - for (const message of fixture.pendingGroup) { - const plaintext = await repository.decryptGroupMessage({ - group: groupJid, - authorJid: aliceJid, - msg: Buffer.from(message.ct, 'base64') - }) - if (Buffer.from(plaintext).toString() !== message.pt) throw new Error(`group mismatch: ${message.pt}`) - } - - // 2. Send something new from the upgraded build. - const outgoing = await repository.encryptMessage({ jid: aliceJid, data: Buffer.from('from-new-bob') }) - - // 3. Roll back: project the session Bob is now using into legacy JSON. - const stored = data.session![sessionAddr] as Uint8Array - const projection = projectLegacySessionRecordV1(stored) - if (projection.status !== 'projected') { - throw new Error(`session is not projectable: ${JSON.stringify(projection.issue)}`) - } - - const outputPath = process.argv[2] - if (!outputPath) { - throw new Error('usage: rollback-step1 ') - } - - writeFileSync( - outputPath, - JSON.stringify({ - outgoing: { type: outgoing.type, ct: Buffer.from(outgoing.ciphertext).toString('base64') }, - projectedSession: fromTypedRecord(projection.record), - senderKey: Buffer.from(data['sender-key']![`${groupJid}::5511900000001::0`] as Uint8Array).toString('base64') - }) - ) - - console.log('step1 ok: consumed pending DM + group, sent one message, projected session back') -} - -main().catch(error => { - console.error('step1 FAILED:', error) - process.exit(1) -}) From 7c268dab081587c605543be3a20ccbd2c9f9b41a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 14:53:25 -0300 Subject: [PATCH 44/71] chore(bridge): pin the core that persists skipped-key seeds MessageKey gained a seed field, and the migration builds that struct with an exhaustive literal, so the bump is a compile error until every site states what it writes. These two sites carry no seed: they read a legacy record whose skipped keys were already stored as bare material. --- packages/whatsapp-rust-bridge/Cargo.lock | 12 ++++++------ packages/whatsapp-rust-bridge/src/storage_adapter.rs | 2 ++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index 85557c268dc..f3a1bafe1fe 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -1406,7 +1406,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore-appstate" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "anyhow", "buffa", @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "wacore-binary" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "bytes", "compact_str", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "wacore-derive" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "proc-macro2", "quote", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "wacore-libsignal" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "aes", "async-lock", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "wacore-noise" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "anyhow", "buffa", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "waproto" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#05c98a1aa4c6d07a0285f5fbdcd418dd74e9cef5" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" dependencies = [ "buffa", "buffa-build", diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 14a8b92f8a0..071ff458c1a 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -399,6 +399,7 @@ impl JsStorageAdapter { cipher_key: Some(key.into()), mac_key: Some(vec![0u8; 32].into()), iv: Some(vec![0u8; 16].into()), + seed: None, }); } @@ -422,6 +423,7 @@ impl JsStorageAdapter { cipher_key: Some(key.into()), mac_key: Some(vec![0u8; 32].into()), iv: Some(vec![0u8; 16].into()), + seed: None, }); } From e673f3ac9443c941b5b7afdd481e1b59be352ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 14:53:25 -0300 Subject: [PATCH 45/71] feat(signal): store sessions in the shape a pre-WASM release reads The session row is written as the legacy JSON record instead of the bridge's bytes, so this branch does not change what sits on disk and going back to an older release is swapping the package rather than converting storage. A record the v1 model cannot express keeps its bridge bytes: reads already accept both shapes, so the fallback costs compatibility for that one row instead of failing the write. It also covers a gap between the two limits, since the engine prunes skipped keys above MAX_MESSAGE_KEYS plus a threshold while the v1 import refuses anything above the limit itself. Costs about 0.3 ms per message and 0.13 cores at 300 msg/s against storing the native bytes, and still runs roughly 3x faster than the JS backend it replaces. --- packages/baileys/src/Signal/libsignal.ts | 44 +++++++++++++++++- .../__tests__/Signal/legacy-session.test.ts | 31 +++++++------ .../src/__tests__/Signal/rollback.test.ts | 46 ++++++++----------- 3 files changed, 78 insertions(+), 43 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index c815d009024..5b8a799beb8 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -10,6 +10,7 @@ import { hasLogger, importLegacySessionRecordV1, processBundleWithSnapshot, + projectLegacySessionRecordV1, ProtocolAddress, SenderKeyDistributionMessage, SenderKeyName, @@ -23,6 +24,7 @@ import type { RecordRef, SignalAuthState, SignalDataSet, + SignalDataTypeMap, SignalKeyStoreWithRecordTransaction, SignalKeyStoreWithTransaction } from '../Types' @@ -41,7 +43,7 @@ import { WAJIDDomains } from '../WABinary' import { hasOpenLegacySession, isLegacySessionEntry, isLegacySessionRecord, legacySessionInfo } from './legacy-session' -import { toTypedRecord } from './legacy-session-codec' +import { fromTypedRecord, toTypedRecord } from './legacy-session-codec' import { LIDMappingStore } from './lid-mapping' /** @@ -104,6 +106,13 @@ function normalizeDecryptError(error: unknown): Error { return asError(error) } +/** + * What the bridge's v1 import accepts per chain. Mirrors MAX_MESSAGE_KEYS in the + * core: the engine prunes above that plus a threshold, so a live session can + * legitimately sit above it for a while. + */ +const MAX_LEGACY_MESSAGE_KEYS = 2000 + /** Does a stored session row — bridge bytes or a pre-WASM record — hold a live state? */ function hasOpenSession(stored: Uint8Array | undefined): boolean { if (!stored) { @@ -254,6 +263,37 @@ export function makeLibSignalRepository( ) } + /** + * Sessions are stored in the shape a pre-WASM release wrote them, so this + * branch does not change what is on disk. A record the v1 model cannot + * express keeps its bridge bytes: `readSessionBytes` accepts both, so the + * fallback costs compatibility with an older release for that one row + * rather than failing the operation. + */ + const toStoredSession = (wireJid: string, session: Uint8Array): SignalDataTypeMap['session'] => { + const projection = projectLegacySessionRecordV1(session) + if (projection.status !== 'projected') { + logger.warn( + { wireJid, issue: projection.issue }, + 'session not expressible in the legacy shape, storing bridge bytes' + ) + return session + } + + // The projection does not bound the skipped-key count but the import + // does, so a chain that grew past the limit would write cleanly and fail + // to load. Keep those as bytes rather than storing a row we cannot read. + const overflowing = projection.record.sessions.some(indexed => + indexed.session.chains.some(chain => chain.messageKeys.length > MAX_LEGACY_MESSAGE_KEYS) + ) + if (overflowing) { + logger.warn({ wireJid }, 'session holds more skipped keys than the legacy shape accepts, storing bridge bytes') + return session + } + + return fromTypedRecord(projection.record) as unknown as SignalDataTypeMap['session'] + } + /** Pre-WASM records are converted on read; bridge records pass straight through. */ const readSessionBytes = async (stored: unknown): Promise => { if (!stored) return undefined @@ -279,7 +319,7 @@ export function makeLibSignalRepository( if (changes.sessionCleared) { update.session = { [wireJid]: null } } else if (changes.session) { - update.session = { [wireJid]: changes.session } + update.session = { [wireJid]: toStoredSession(wireJid, changes.session) } } if (changes.identity) { diff --git a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts index 715da65c9d7..fbe71d974c7 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -216,8 +216,16 @@ describe('repository on a pre-WASM auth state', () => { expect(data.session![addr]).toBeDefined() }) - /** Bytes of a real bridge session, exactly as injectE2ESession stores them. */ - const bridgeSessionBytes = async (jid: string, preKeyId: number): Promise => { + /** + * Sessions are stored in the legacy shape, so rows are compared by the base + * keys they hold rather than byte-for-byte: that is what identifies the + * session, and it survives the conversion. + */ + const sessionIdentity = (row: unknown): string[] => + Object.keys((row as { _sessions?: Record })?._sessions ?? {}).sort() + + /** A real session, exactly as injectE2ESession leaves it in storage. */ + const bridgeSessionBytes = async (jid: string, preKeyId: number): Promise => { const peerCreds = initAuthCreds() const { repository, data } = makeRepository() await repository.injectE2ESession({ @@ -237,8 +245,7 @@ describe('repository on a pre-WASM auth state', () => { } }) - const stored = Object.values(data.session!)[0] - return stored as Uint8Array + return Object.values(data.session!)[0] } it('keeps a live LID session instead of overwriting it with a post-upgrade PN one', async () => { @@ -259,9 +266,7 @@ describe('repository on a pre-WASM auth state', () => { expect(result.migrated).toBe(0) // The LID row must still hold the newer session, byte for byte. - expect(Buffer.from(data.session![lidAddr] as Uint8Array).toString('base64')).toBe( - Buffer.from(newer).toString('base64') - ) + expect(sessionIdentity(data.session![lidAddr])).toEqual(sessionIdentity(newer)) expect(data.session![addr]).toBeDefined() }) @@ -312,8 +317,8 @@ describe('repository on a pre-WASM auth state', () => { const result = await repository.migrateSession(pnJid, lidJid) expect(result.migrated).toBe(1) - expect(Buffer.from(data.session![`18000000000004_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( - Buffer.from(pnBytes) + expect(sessionIdentity(data.session![`18000000000004_${WAJIDDomains.HOSTED_LID}.99`])).toEqual( + sessionIdentity(pnBytes) ) expect(data.session![plainAddr]).toBeUndefined() }) @@ -334,8 +339,8 @@ describe('repository on a pre-WASM auth state', () => { expect(result.migrated).toBe(1) // The hosted row is the shape written today, so it is the one that moves. - expect(Buffer.from(data.session![`18000000000005_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( - Buffer.from(hostedBytes) + expect(sessionIdentity(data.session![`18000000000005_${WAJIDDomains.HOSTED_LID}.99`])).toEqual( + sessionIdentity(hostedBytes) ) expect(data.session![hostedAddr]).toBeUndefined() // The other row is left alone rather than deleted along with it. @@ -357,8 +362,8 @@ describe('repository on a pre-WASM auth state', () => { const result = await repository.migrateSession(pnJid, lidJid) expect(result.migrated).toBe(1) - expect(Buffer.from(data.session![`18000000000006_${WAJIDDomains.HOSTED_LID}.99`] as Uint8Array)).toEqual( - Buffer.from(liveBytes) + expect(sessionIdentity(data.session![`18000000000006_${WAJIDDomains.HOSTED_LID}.99`])).toEqual( + sessionIdentity(liveBytes) ) expect(data.session![plainAddr]).toBeUndefined() // The closed row is left behind rather than migrated. diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts index b6b58d039b0..d610da5c9e9 100644 --- a/packages/baileys/src/__tests__/Signal/rollback.test.ts +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals' import P from 'pino' -import { projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' +import type { LegacySessionRecord } from '../../Signal/legacy-session' +import { isLegacySessionRecord } from '../../Signal/legacy-session' import { fromTypedRecord, toTypedRecord } from '../../Signal/legacy-session-codec' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' @@ -8,9 +9,9 @@ import { addTransactionCapability } from '../../Utils/auth-utils' import fixture from '../fixtures/legacy-session-rc9.json' /** - * Going back to a pre-WASM release. Bob upgrades carrying an rc.9 auth state, - * uses it, and then changes his mind: the session he is now on has to be - * expressible in the legacy JSON shape the JS libsignal reads. + * Going back to a pre-WASM release. Sessions are written in the shape the JS + * libsignal reads, so a rollback is swapping the package: there is no + * conversion step to get wrong. * * Sender keys have no such projection. A group key written by this backend * cannot be read by the old one, so that limitation is asserted here rather @@ -70,7 +71,7 @@ const { aliceJid, groupJid } = fixture.jids const sessionAddr = '5511900000001.0' describe('rolling back to a pre-WASM release', () => { - it('projects a session this backend advanced into the legacy shape', async () => { + it('leaves a session it advanced in the shape the old build reads', async () => { const bob = makeBob() // Use the upgraded state the way a running client would. @@ -84,38 +85,27 @@ describe('rolling back to a pre-WASM release', () => { await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('from-new-bob') }) - const projection = projectLegacySessionRecordV1(bob.data.session![sessionAddr] as Uint8Array) - // Narrowed by hand: expect() does not tell the compiler which arm this is. - if (projection.status !== 'projected') { - throw new Error(`not projectable: ${JSON.stringify(projection.issue)}`) - } - - const legacy = fromTypedRecord(projection.record) - // The shape the JS libsignal expects: a record keyed by base64 index keys. - const sessions = legacy._sessions ?? {} - expect(Object.keys(sessions).length).toBeGreaterThan(0) - for (const entry of Object.values(sessions)) { + // No conversion step: the row is already what a pre-WASM release writes, + // which is what makes going back a matter of swapping the package. + const stored = bob.data.session![sessionAddr] as { _sessions?: Record } + expect(isLegacySessionRecord(stored)).toBe(true) + expect(Object.keys(stored._sessions ?? {}).length).toBeGreaterThan(0) + for (const entry of Object.values(stored._sessions ?? {})) { expect(entry).toHaveProperty('currentRatchet') expect(entry).toHaveProperty('indexInfo') } }) - it('round-trips the projection back through the bridge unchanged', async () => { + it('keeps the stored record stable through the bridge round trip', async () => { const bob = makeBob() await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('one more') }) - const bytes = bob.data.session![sessionAddr] as Uint8Array - const projection = projectLegacySessionRecordV1(bytes) - if (projection.status !== 'projected') { - throw new Error(`not projectable: ${JSON.stringify(projection.issue)}`) - } - - // Legacy JSON -> typed model -> legacy JSON must be stable, or a rollback - // would hand the old build a record it wrote differently than it reads. - const legacy = fromTypedRecord(projection.record) - const again = fromTypedRecord(toTypedRecord(legacy)) + const stored = bob.data.session![sessionAddr] as LegacySessionRecord + // legacy JSON -> typed model -> legacy JSON must be stable, or the old + // build would read back something we never meant to write. + const again = fromTypedRecord(toTypedRecord(stored)) - expect(JSON.stringify(again)).toBe(JSON.stringify(legacy)) + expect(JSON.stringify(again)).toBe(JSON.stringify(stored)) }) it('leaves a group sender key in a shape the old build cannot read', async () => { From 3aaa17fca7c0c922c9443d1895ddd1d630c5430c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 15:04:22 -0300 Subject: [PATCH 46/71] test(signal): say what the assertions actually check Two comments still described byte comparisons that the legacy storage shape replaced with a base-key check. The sender-key assertion also had to assert the row exists first: a missing one would make Buffer.from throw on its own, and the toThrow below would pass having proved nothing about the stored shape. --- packages/baileys/src/__tests__/Signal/rollback.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts index d610da5c9e9..2df9be3a891 100644 --- a/packages/baileys/src/__tests__/Signal/rollback.test.ts +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -118,10 +118,14 @@ describe('rolling back to a pre-WASM release', () => { msg: Buffer.from(fixture.pendingGroup[0]!.ct, 'base64') }) - const stored = bob.data['sender-key']![`${groupJid}::5511900000001::0`] as Uint8Array + const stored = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] as Uint8Array | undefined + // Asserted before the throw below: a missing row would make Buffer.from + // throw on its own and the assertion would pass having proved nothing. + expect(stored).toBeDefined() + // The JS backend parses this row as JSON. Once this backend has written // it, that parse fails: there is no projection for sender keys, so a // rollback needs the group keys to be redistributed. - expect(() => JSON.parse(Buffer.from(stored).toString())).toThrow() + expect(() => JSON.parse(Buffer.from(stored!).toString())).toThrow() }) }) From ffb08022946154fb161c9350140f7fc1b7370d0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 15:11:46 -0300 Subject: [PATCH 47/71] fix(signal): rebuild a session the bridge cannot read A stored row that fails to decode was raised on read, so every retry hit the same record and the conversation stayed stuck until someone deleted it by hand. The JS backend this replaces caught the deserialization failure and treated the session as absent. A prekey message carries everything needed to build a replacement, so that path retries without the row. A whisper message still fails, since there is nothing to rebuild from and starting a session that decrypts nothing would be worse. The retry only runs after a decode failure, so the happy path is unchanged. The sender-key assertion now checks the row really holds bytes: a legacy object would make Buffer.from throw on its own and pass the toThrow below. --- packages/baileys/src/Signal/libsignal.ts | 58 ++++++++++++++----- .../src/__tests__/Signal/rollback.test.ts | 11 ++-- .../__tests__/Signal/snapshot-session.test.ts | 31 ++++++++++ 3 files changed, 81 insertions(+), 19 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 5b8a799beb8..ff567d25463 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -113,6 +113,12 @@ function normalizeDecryptError(error: unknown): Error { */ const MAX_LEGACY_MESSAGE_KEYS = 2000 +/** The bridge refuses a session it cannot decode; the row has to be replaced. */ +function isUnreadableSession(error: unknown): boolean { + const message = typeof error === 'string' ? error : error instanceof Error ? error.message : '' + return message.includes('snapshot.session') +} + /** Does a stored session row — bridge bytes or a pre-WASM record — hold a live state? */ function hasOpenSession(stored: Uint8Array | undefined): boolean { if (!stored) { @@ -232,7 +238,7 @@ export function makeLibSignalRepository( private: creds.signedIdentityKey.private }, registrationId: creds.registrationId, - session: await readSessionBytes(sessions[wireJid]), + session: await readSessionBytes(wireJid, sessions[wireJid]), peerIdentity: identities[wireJid], preKeys, signedPreKeys: [ @@ -295,18 +301,27 @@ export function makeLibSignalRepository( } /** Pre-WASM records are converted on read; bridge records pass straight through. */ - const readSessionBytes = async (stored: unknown): Promise => { + const readSessionBytes = async (wireJid: string, stored: unknown): Promise => { if (!stored) return undefined - if (isLegacySessionRecord(stored)) { - if (!hasOpenLegacySession(stored)) return undefined - return importLegacySessionRecordV1(toTypedRecord(stored), { - identityKey: generateSignalPubKey(creds.signedIdentityKey.public), - registrationId: creds.registrationId - }) - } + try { + if (isLegacySessionRecord(stored)) { + if (!hasOpenLegacySession(stored)) return undefined + return importLegacySessionRecordV1(toTypedRecord(stored), { + identityKey: generateSignalPubKey(creds.signedIdentityKey.public), + registrationId: creds.registrationId + }) + } - return stored as Uint8Array + return stored as Uint8Array + } catch (error) { + // A row we cannot read is reported as absent, not raised: a prekey + // message carries everything needed to build a replacement, and + // failing here would leave the conversation stuck on the bad row + // until someone deleted it by hand. + logger.warn({ wireJid, err: error }, 'stored session is unreadable, treating it as absent') + return undefined + } } /** @@ -385,10 +400,25 @@ export function makeLibSignalRepository( jid, async (snapshot, address) => { try { - const out = - type === 'pkmsg' - ? await decryptPreKeyWithSnapshot(snapshot, address, ciphertext) - : await decryptWhisperWithSnapshot(snapshot, address, ciphertext) + let out + if (type === 'pkmsg') { + try { + out = await decryptPreKeyWithSnapshot(snapshot, address, ciphertext) + } catch (error) { + // The row is there but the bridge cannot read it. A prekey + // message carries everything needed to build a replacement, + // so retry without it rather than leaving the conversation + // stuck on a record only a manual delete would clear. + if (!snapshot.session || !isUnreadableSession(error)) { + throw error + } + + logger.warn({ jid }, 'stored session is unreadable, rebuilding from the prekey message') + out = await decryptPreKeyWithSnapshot({ ...snapshot, session: undefined }, address, ciphertext) + } + } else { + out = await decryptWhisperWithSnapshot(snapshot, address, ciphertext) + } if (out.changes.sessionCleared) { logger.info({ jid }, 'identity key changed, session will be re-established') diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts index 2df9be3a891..0be6ae613bf 100644 --- a/packages/baileys/src/__tests__/Signal/rollback.test.ts +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -118,14 +118,15 @@ describe('rolling back to a pre-WASM release', () => { msg: Buffer.from(fixture.pendingGroup[0]!.ct, 'base64') }) - const stored = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] as Uint8Array | undefined - // Asserted before the throw below: a missing row would make Buffer.from - // throw on its own and the assertion would pass having proved nothing. - expect(stored).toBeDefined() + const stored = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] + // Asserted before the throw below: a missing row, or one still holding the + // legacy object, would make Buffer.from throw on its own and the assertion + // would pass without proving the bridge wrote bytes. + expect(ArrayBuffer.isView(stored)).toBe(true) // The JS backend parses this row as JSON. Once this backend has written // it, that parse fails: there is no projection for sender keys, so a // rollback needs the group keys to be redistributed. - expect(() => JSON.parse(Buffer.from(stored!).toString())).toThrow() + expect(() => JSON.parse(Buffer.from(stored as Uint8Array).toString())).toThrow() }) }) diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index fd87bc42919..20ea929aa0e 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -227,6 +227,37 @@ describe('snapshot session path', () => { expect(Buffer.from(received).toString()).toBe('welcome back') }) + it('rebuilds from a prekey message when the stored session is unreadable', async () => { + // A corrupt row used to be raised on read, so every retry failed and the + // conversation stayed stuck until someone deleted it by hand. A prekey + // message carries everything needed to build a replacement. + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('hello') }) + + bob.recorder.data['session'] = { '1111111111.0': Uint8Array.from([9, 9, 9, 9]) } + + const plaintext = await bob.repository.decryptMessage({ jid: aliceJid, ...opener }) + + expect(Buffer.from(plaintext).toString()).toBe('hello') + }) + + it('still refuses a whisper message when the stored session is unreadable', async () => { + // Without a prekey there is nothing to rebuild from, so the failure has + // to surface rather than silently start a session that decrypts nothing. + const alice = makeParty() + const bob = makeParty() + await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) + const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('one') }) + await bob.repository.decryptMessage({ jid: aliceJid, ...opener }) + const second = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('two') }) + + bob.recorder.data['session'] = { '1111111111.0': Uint8Array.from([9, 9, 9, 9]) } + + await expect(bob.repository.decryptMessage({ jid: aliceJid, ...second })).rejects.toThrow() + }) + it('leaves storage untouched when the operation fails', async () => { const alice = makeParty() const bob = makeParty() From d5faeb3172c303ec467c1d093a80f574aaca2634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 15:24:58 -0300 Subject: [PATCH 48/71] test(signal): prove the rebuilt session is persisted The recovery test only checked that decrypt returned plaintext, so a path that rebuilt the session and dropped the changeset would have passed while the next message hit the same bad row. It now asserts the stored record was replaced with a live one, that the pre-key the message consumed is gone, and that the recovered session carries a reply on its own. --- .../__tests__/Signal/snapshot-session.test.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts index 20ea929aa0e..3b9e5c9fead 100644 --- a/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from '@jest/globals' import P from 'pino' +import { hasOpenLegacySession, isLegacySessionRecord } from '../../Signal/legacy-session' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, @@ -236,11 +237,24 @@ describe('snapshot session path', () => { await alice.repository.injectE2ESession({ jid: bobJid, session: await bundleOf(bob, 1) }) const opener = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from('hello') }) - bob.recorder.data['session'] = { '1111111111.0': Uint8Array.from([9, 9, 9, 9]) } + const corrupt = Uint8Array.from([9, 9, 9, 9]) + bob.recorder.data['session'] = { '1111111111.0': corrupt } const plaintext = await bob.repository.decryptMessage({ jid: aliceJid, ...opener }) - expect(Buffer.from(plaintext).toString()).toBe('hello') + + // Decrypting is half of it: the rebuilt session has to land, or the next + // message hits the same bad row and nothing was actually recovered. + const stored = bob.recorder.data['session']!['1111111111.0'] + expect(stored).not.toEqual(corrupt) + expect(isLegacySessionRecord(stored)).toBe(true) + expect(hasOpenLegacySession(stored as never)).toBe(true) + // ...and the pre-key the message consumed is gone, as on the normal path. + expect(bob.recorder.data['pre-key']?.[1]).toBeUndefined() + + // The recovered session carries the conversation on its own. + const reply = await bob.repository.encryptMessage({ jid: aliceJid, data: Buffer.from('back') }) + expect(Buffer.from(await alice.repository.decryptMessage({ jid: bobJid, ...reply })).toString()).toBe('back') }) it('still refuses a whisper message when the stored session is unreadable', async () => { From 3606e3206f29a71caf5f609cae863f981f1e1663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 16:16:40 -0300 Subject: [PATCH 49/71] feat(bridge): project a sender-key record into the v1 shape Every field of a sender-key state has a v1 counterpart, so unlike a session this conversion loses nothing: the group format already persists seeds rather than derived material. The record is exposed as a function instead of being applied inside store_sender_key, so the bridge keeps writing native bytes and the caller decides what shape its storage keeps. Reading a legacy record also stopped carrying an empty private signing key as present. The JS backend wrote an empty Buffer for every sender key received from someone else, and Some(empty) fails validation the moment anything reads the record's components. --- .../src/legacy_session.rs | 13 ++ .../src/storage_adapter.rs | 142 +++++++++++++++++- 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/packages/whatsapp-rust-bridge/src/legacy_session.rs b/packages/whatsapp-rust-bridge/src/legacy_session.rs index cf20c1ff654..e48ce654146 100644 --- a/packages/whatsapp-rust-bridge/src/legacy_session.rs +++ b/packages/whatsapp-rust-bridge/src/legacy_session.rs @@ -478,3 +478,16 @@ pub fn project_legacy_session_record_v1( }, } } + +/// The JS backend stored a sender-key record as UTF-8 JSON. Callers that keep +/// that shape on disk convert here; the storage adapter itself stays neutral, +/// so a consumer that prefers the native bytes is unaffected. +#[wasm_bindgen(js_name = projectLegacySenderKeyRecordV1)] +pub fn project_legacy_sender_key_record_v1(bytes: &[u8]) -> Result { + let record = wacore_libsignal::protocol::SenderKeyRecord::deserialize(bytes) + .map_err(|error| JsValue::from_str(&format!("recordBytes: {error}")))?; + let json = crate::storage_adapter::legacy_sender_key::serialize(record) + .map_err(|error| JsValue::from_str(&format!("legacy sender key: {error}")))?; + + Ok(byte_array(&json)) +} diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 071ff458c1a..d557d2fbeae 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -498,7 +498,12 @@ impl JsStorageAdapter { .ok_or_else(|| invalid_js_data("migrate_sender_key", "Missing senderSigningKey"))?; let public_key = get_bytes_from_buffer_json(&sender_signing_key_obj, "public")?.unwrap_or_default(); - let private_key = get_bytes_from_buffer_json(&sender_signing_key_obj, "private")?; + // The JS backend wrote an empty Buffer for a state it has no private + // key for, which is every sender key received from someone else. An + // empty buffer is not a key: carrying it as Some() makes the record + // fail validation the moment anything reads its components. + let private_key = get_bytes_from_buffer_json(&sender_signing_key_obj, "private")? + .filter(|key| !key.is_empty()); let sender_message_keys_arr = get_object(&state_obj, "senderMessageKeys") .map(|v| js_sys::Array::from(&v)) @@ -1164,6 +1169,96 @@ impl SenderKeyStore for JsStorageAdapter { } } +/// The JS backend stored a sender-key record as UTF-8 JSON, and +/// `migrate_legacy_sender_key` above reads exactly that. Writing it back keeps +/// the row readable by a pre-WASM release: unlike a session, every field of a +/// sender-key state has a v1 counterpart, so nothing is lost either way. +pub mod legacy_sender_key { + use serde::Serialize; + use wacore_libsignal::protocol::SenderKeyRecord as CoreSenderKeyRecord; + use wacore_libsignal::protocol::error::Result as SignalResult; + + /// `JSON.stringify` of a Buffer, which is the shape the JS backend wrote. + #[derive(Serialize)] + struct Bytes<'a> { + #[serde(rename = "type")] + kind: &'static str, + data: &'a [u8], + } + + fn bytes(data: &[u8]) -> Bytes<'_> { + Bytes { + kind: "Buffer", + data, + } + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct ChainKey<'a> { + iteration: u32, + seed: Bytes<'a>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct SigningKey<'a> { + public: Bytes<'a>, + #[serde(skip_serializing_if = "Option::is_none")] + private: Option>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct MessageKey<'a> { + iteration: u32, + seed: Bytes<'a>, + } + + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + struct State<'a> { + sender_key_id: u32, + sender_chain_key: ChainKey<'a>, + sender_signing_key: SigningKey<'a>, + sender_message_keys: Vec>, + } + + pub fn serialize(record: CoreSenderKeyRecord) -> SignalResult> { + let components = record.into_components()?; + let states: Vec> = components + .states + .iter() + .map(|state| State { + sender_key_id: state.key_id, + sender_chain_key: ChainKey { + iteration: state.chain_key.iteration, + seed: bytes(&state.chain_key.seed), + }, + sender_signing_key: SigningKey { + public: bytes(&state.signing_key.public), + private: state.signing_key.private.as_deref().map(bytes), + }, + sender_message_keys: state + .message_keys + .iter() + .map(|key| MessageKey { + iteration: key.iteration, + seed: bytes(&key.seed), + }) + .collect(), + }) + .collect(); + + serde_json::to_vec(&states).map_err(|error| { + wacore_libsignal::protocol::SignalProtocolError::InvalidState( + "store_sender_key", + format!("could not write the legacy shape: {error}"), + ) + }) + } +} + #[cfg(test)] mod js_value_tests { use super::{get_bytes_from_buffer_json, get_number, get_object}; @@ -1243,3 +1338,48 @@ mod js_value_tests { ); } } + +#[cfg(test)] +mod legacy_sender_key_tests { + use super::legacy_sender_key; + use wacore_libsignal::protocol::{KeyPair, SenderKeyRecord}; + use wasm_bindgen_test::wasm_bindgen_test as test; + + fn record() -> SenderKeyRecord { + let mut rng = rand::make_rng::(); + let signing = KeyPair::generate(&mut rng); + let mut record = SenderKeyRecord::new_empty(); + record.add_sender_key_state( + 3, + 7, + 42, + &[9u8; 32], + signing.public_key, + Some(signing.private_key), + ); + record + } + + #[test] + fn writes_the_shape_the_js_backend_wrote() { + let bytes = legacy_sender_key::serialize(record()).expect("serialize"); + let text = String::from_utf8(bytes).expect("utf8"); + + assert!( + text.starts_with('['), + "legacy records are a JSON array: {text}" + ); + for field in [ + "senderKeyId", + "senderChainKey", + "senderSigningKey", + "senderMessageKeys", + ] { + assert!(text.contains(field), "missing {field} in {text}"); + } + assert!( + text.contains(r#""type":"Buffer""#), + "bytes use the Buffer envelope" + ); + } +} From f5031d722100bcf1b1b76d0b2a1471548c09b236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 16:16:40 -0300 Subject: [PATCH 50/71] feat(signal): store group sender keys in the pre-WASM shape The last row that still diverged from what an older release reads. With this, rolling back keeps groups working instead of needing every sender key redistributed, verified by running a group through this branch and then handing the storage to develop: it read the pending message, kept sending, and adopted a new key without any conversion step. Also drops the empty record written before processing a distribution message. The builder creates one when the row is absent, and that pre-write was the only remaining path storing a sender key outside the policy above. --- packages/baileys/src/Signal/libsignal.ts | 18 +++++----- .../src/__tests__/Signal/rollback.test.ts | 35 ++++++++++++++----- 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index ff567d25463..9b1baf55d20 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -10,11 +10,11 @@ import { hasLogger, importLegacySessionRecordV1, processBundleWithSnapshot, + projectLegacySenderKeyRecordV1, projectLegacySessionRecordV1, ProtocolAddress, SenderKeyDistributionMessage, SenderKeyName, - SenderKeyRecord, SessionRecord, setLogger } from 'whatsapp-rust-bridge' @@ -382,14 +382,9 @@ export function makeLibSignalRepository( // then clobber with an empty record. Removing it eliminates that // race window without changing observable behavior — the in-lock // re-check handles the first-time-seeing-this-sender case. - return parsedKeys.transactWith({ records: [{ type: 'sender-key', id: senderNameStr }] }, async () => { - const { [senderNameStr]: senderKey } = await auth.keys.get('sender-key', [senderNameStr]) - if (!senderKey) { - await storage.storeSenderKey(senderNameStr, new SenderKeyRecord().serialize()) - } - - await builder.process(senderName, senderMsg) - }) + return parsedKeys.transactWith({ records: [{ type: 'sender-key', id: senderNameStr }] }, async () => + builder.process(senderName, senderMsg) + ) }, async decryptMessage({ jid, type, ciphertext }) { // A prekey message names the one-time key it consumes, so the snapshot @@ -977,7 +972,10 @@ function signalStorage( return key ?? null }, storeSenderKey: async (keyId: string, keyBytes: Uint8Array) => { - await keys.set({ 'sender-key': { [keyId]: keyBytes.slice() } }) + // Same policy as sessions: the row keeps the shape a pre-WASM release + // reads. Every field of a sender-key state has a v1 counterpart, so + // unlike a session this conversion cannot lose anything. + await keys.set({ 'sender-key': { [keyId]: projectLegacySenderKeyRecordV1(keyBytes) } }) }, getOurRegistrationId: () => creds.registrationId, getOurIdentity: () => { diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts index 0be6ae613bf..3120ac1a16a 100644 --- a/packages/baileys/src/__tests__/Signal/rollback.test.ts +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -108,10 +108,9 @@ describe('rolling back to a pre-WASM release', () => { expect(JSON.stringify(again)).toBe(JSON.stringify(stored)) }) - it('leaves a group sender key in a shape the old build cannot read', async () => { + it('leaves a group sender key in the shape the old build reads', async () => { const bob = makeBob() - // Reading the legacy row is fine; writing is what changes the shape. await bob.repository.decryptGroupMessage({ group: groupJid, authorJid: aliceJid, @@ -119,14 +118,32 @@ describe('rolling back to a pre-WASM release', () => { }) const stored = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] - // Asserted before the throw below: a missing row, or one still holding the - // legacy object, would make Buffer.from throw on its own and the assertion - // would pass without proving the bridge wrote bytes. expect(ArrayBuffer.isView(stored)).toBe(true) - // The JS backend parses this row as JSON. Once this backend has written - // it, that parse fails: there is no projection for sender keys, so a - // rollback needs the group keys to be redistributed. - expect(() => JSON.parse(Buffer.from(stored as Uint8Array).toString())).toThrow() + // The JS backend parses this row as JSON, so a rollback keeps the group + // working instead of needing the sender keys redistributed. + const states = JSON.parse(Buffer.from(stored as Uint8Array).toString()) + expect(Array.isArray(states)).toBe(true) + expect(states.length).toBeGreaterThan(0) + for (const state of states) { + expect(state).toHaveProperty('senderKeyId') + expect(state.senderChainKey).toHaveProperty('seed') + expect(state.senderSigningKey).toHaveProperty('public') + } + }) + + it('advances that row without leaving the legacy shape', async () => { + const bob = makeBob() + const first = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] + + await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: Buffer.from(fixture.pendingGroup[0]!.ct, 'base64') + }) + + const after = bob.data['sender-key']?.[`${groupJid}::5511900000001::0`] + expect(after).not.toEqual(first) + expect(() => JSON.parse(Buffer.from(after as Uint8Array).toString())).not.toThrow() }) }) From 1e8a8f2f4d5fbe8e99aca6d32e248b6fa5892e37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 16:43:10 -0300 Subject: [PATCH 51/71] fix(bridge): keep the empty private signing key in the projected record The JS backend wrote senderSigningKey.private as an empty Buffer for every sender key received from someone else, so omitting the field made the row distinguishable from one it produced. It reads either shape, but the point of this projection is to be indistinguishable, not merely readable. The rollback suite's header still claimed sender keys had no projection and could not be read by the old build, which the tests below it now disprove. --- .../src/__tests__/Signal/rollback.test.ts | 12 ++++---- .../src/storage_adapter.rs | 29 +++++++++++++++++-- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts index 3120ac1a16a..6b49e6047ee 100644 --- a/packages/baileys/src/__tests__/Signal/rollback.test.ts +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -9,13 +9,11 @@ import { addTransactionCapability } from '../../Utils/auth-utils' import fixture from '../fixtures/legacy-session-rc9.json' /** - * Going back to a pre-WASM release. Sessions are written in the shape the JS - * libsignal reads, so a rollback is swapping the package: there is no - * conversion step to get wrong. - * - * Sender keys have no such projection. A group key written by this backend - * cannot be read by the old one, so that limitation is asserted here rather - * than left to be discovered during a rollback. + * Going back to a pre-WASM release. Sessions and group sender keys are both + * written in the shape the JS backend reads, so a rollback is swapping the + * package: there is no conversion step to get wrong, and no group key to + * redistribute. These cases pin that, since nothing else would catch the day a + * row starts being written in the native shape instead. */ const logger = P({ level: 'silent' }) diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index d557d2fbeae..0c804dee4e4 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1204,8 +1204,10 @@ pub mod legacy_sender_key { #[serde(rename_all = "camelCase")] struct SigningKey<'a> { public: Bytes<'a>, - #[serde(skip_serializing_if = "Option::is_none")] - private: Option>, + /// Always written, empty when there is none: that is what the JS backend + /// produced for every sender key received from someone else, and the + /// point of this shape is to be indistinguishable from what it wrote. + private: Bytes<'a>, } #[derive(Serialize)] @@ -1237,7 +1239,7 @@ pub mod legacy_sender_key { }, sender_signing_key: SigningKey { public: bytes(&state.signing_key.public), - private: state.signing_key.private.as_deref().map(bytes), + private: bytes(state.signing_key.private.as_deref().unwrap_or(&[])), }, sender_message_keys: state .message_keys @@ -1382,4 +1384,25 @@ mod legacy_sender_key_tests { "bytes use the Buffer envelope" ); } + + /// A sender key received from someone else has no private signing key, and + /// the JS backend still wrote the field as an empty Buffer. Omitting it + /// would make the row distinguishable from one that backend produced. + #[test] + fn keeps_an_empty_private_signing_key_rather_than_dropping_it() { + let mut rng = rand::make_rng::(); + let signing = KeyPair::generate(&mut rng); + let mut record = SenderKeyRecord::new_empty(); + record + .add_sender_key_state(3, 7, 42, &[9u8; 32], signing.public_key, None) + .expect("state added"); + + let bytes = legacy_sender_key::serialize(record).expect("serialize"); + let text = String::from_utf8(bytes).expect("utf8"); + + assert!( + text.contains(r#""private":{"type":"Buffer","data":[]}"#), + "got {text}" + ); + } } From 179791712a77f465d0ab4f73f0617a541b4fd70c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 17:14:19 -0300 Subject: [PATCH 52/71] build(bridge): add a profiling build that keeps symbols A CPU profile of the release artifact shows wasm-function[N]: the release profile strips, and wasm-opt drops the name section. wasm-pack's own --profiling still builds the release profile, so `pnpm build:profile` drives cargo and wasm-bindgen directly, skips wasm-opt, and resolves the wasm-bindgen matching the crate's schema from wasm-pack's cache. The release path is untouched: same flags, same output size. --- packages/whatsapp-rust-bridge/Cargo.toml | 11 ++++ packages/whatsapp-rust-bridge/package.json | 1 + .../scripts/build-wasm.mjs | 65 +++++++++++++++++-- 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/packages/whatsapp-rust-bridge/Cargo.toml b/packages/whatsapp-rust-bridge/Cargo.toml index 430b284d4bd..8962ea31670 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -114,6 +114,17 @@ web-sys = { version = "0.3", features = [ [dev-dependencies] wasm-bindgen-test = "0.3" +# Keeps the name section and debug info so a CPU profile shows real symbols +# instead of wasm-function[N]. Used by `pnpm build:profile`, never by a release. +[package.metadata.wasm-pack.profile.profiling] +wasm-opt = false + +[profile.profiling] +inherits = "release" +debug = 2 +strip = false +lto = false + [profile.release] lto = "fat" opt-level = 3 diff --git a/packages/whatsapp-rust-bridge/package.json b/packages/whatsapp-rust-bridge/package.json index 2a3906396e0..3fb613cc32d 100644 --- a/packages/whatsapp-rust-bridge/package.json +++ b/packages/whatsapp-rust-bridge/package.json @@ -40,6 +40,7 @@ "prebuild": "node scripts/clean.mjs", "postbuild": "tsc -p tsconfig.json --outDir dist", "build": "pnpm run prebuild && pnpm run build:wasm && pnpm run build:ts && pnpm run postbuild", + "build:profile": "WHATSAPP_RUST_BRIDGE_PROFILE=1 pnpm run build", "test:typecheck": "tsc -p tsconfig.test.json --noEmit", "test:rust": "wasm-pack test --node", "test:rust:if-available": "node scripts/test-rust.mjs", diff --git a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs index 5ea9aa69d74..72b09c8d4f8 100644 --- a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs +++ b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process' -import { copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -47,20 +47,71 @@ function run(cmd, args, env = {}) { } } +// A profiling build keeps the name section so a CPU profile resolves symbols; +// wasm-opt would strip it, and its rewrites make the remaining frames hard to +// map back to source. Never used for a release artifact. +const profiling = process.env.WHATSAPP_RUST_BRIDGE_PROFILE === '1' + +// wasm-pack downloads the wasm-bindgen matching the crate's schema; a globally +// installed one usually does not match and refuses the module. +function wasmBindgen() { + const wanted = readFileSync(resolve(root, 'Cargo.lock'), 'utf8').match( + /name = "wasm-bindgen"\nversion = "([^"]+)"/ + )?.[1] + + const cacheRoot = resolve(process.env.HOME ?? '', '.cache/.wasm-pack') + if (wanted && existsSync(cacheRoot)) { + for (const entry of readdirSync(cacheRoot)) { + const candidate = resolve(cacheRoot, entry, 'wasm-bindgen') + if (!existsSync(candidate)) continue + + const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }).stdout?.trim() + if (version?.endsWith(wanted)) return candidate + } + } + + return 'wasm-bindgen' +} + function build(variant) { const isSimd = variant === 'simd' const rustflags = isSimd ? '-C target-feature=+simd128' : '-C target-feature=-simd128' console.log(`\n=== Building ${variant} ===`) - const wasmPackArgs = ['build', '--target', 'web', '--out-dir', 'pkg', '--no-pack', '--no-opt'] - if (cargoFeatures) { - wasmPackArgs.push('--features', cargoFeatures) + if (profiling) { + // wasm-pack's --profiling still builds the release profile, which strips. + // Drive cargo and wasm-bindgen directly so the custom profile applies and + // the name section survives. + const cargoArgs = ['build', '--profile', 'profiling', '--target', 'wasm32-unknown-unknown'] + if (cargoFeatures) { + cargoArgs.push('--features', cargoFeatures) + } + + run('cargo', cargoArgs, { RUSTFLAGS: rustflags }) + run(wasmBindgen(), [ + '--target', + 'web', + '--out-dir', + 'pkg', + '--keep-debug', + 'target/wasm32-unknown-unknown/profiling/whatsapp_rust_bridge.wasm' + ]) + } else { + const wasmPackArgs = ['build', '--target', 'web', '--out-dir', 'pkg', '--no-pack', '--no-opt'] + if (cargoFeatures) { + wasmPackArgs.push('--features', cargoFeatures) + } + + run('wasm-pack', wasmPackArgs, { RUSTFLAGS: rustflags }) } - run('wasm-pack', wasmPackArgs, { RUSTFLAGS: rustflags }) const outFile = resolve(outDir, `${variant}.wasm`) - const optFlags = [...wasmOptFlags, isSimd ? '--enable-simd' : '--disable-simd', pkgWasm, '-o', outFile] - run('wasm-opt', optFlags) + if (profiling) { + copyFileSync(pkgWasm, outFile) + } else { + const optFlags = [...wasmOptFlags, isSimd ? '--enable-simd' : '--disable-simd', pkgWasm, '-o', outFile] + run('wasm-opt', optFlags) + } const size = statSync(outFile).size console.log(` → ${outFile} (${(size / 1024).toFixed(1)} KB)`) From 3d9eca8f52e09a4e3e4ecd51e746a8aff8a286c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 17:50:37 -0300 Subject: [PATCH 53/71] build(bridge): fail the profiling build with an actionable message Falling through to a global wasm-bindgen is worse than stopping: it is almost never the version the crate was linked against, and the error it produces talks about schema numbers instead of what to do. It now names the version and the two ways to get it. The wasm-pack metadata profile went with it. That path drives cargo directly, so the section was never read. --- packages/whatsapp-rust-bridge/Cargo.toml | 6 ++---- packages/whatsapp-rust-bridge/scripts/build-wasm.mjs | 10 +++++++++- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/whatsapp-rust-bridge/Cargo.toml b/packages/whatsapp-rust-bridge/Cargo.toml index 8962ea31670..52c9b294154 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -115,10 +115,8 @@ web-sys = { version = "0.3", features = [ wasm-bindgen-test = "0.3" # Keeps the name section and debug info so a CPU profile shows real symbols -# instead of wasm-function[N]. Used by `pnpm build:profile`, never by a release. -[package.metadata.wasm-pack.profile.profiling] -wasm-opt = false - +# instead of wasm-function[N]. `pnpm build:profile` selects it through cargo +# directly, since wasm-pack's own --profiling still builds the release profile. [profile.profiling] inherits = "release" debug = 2 diff --git a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs index 72b09c8d4f8..ba3a9c929b2 100644 --- a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs +++ b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs @@ -70,7 +70,15 @@ function wasmBindgen() { } } - return 'wasm-bindgen' + // Falling through to a global wasm-bindgen is worse than stopping: it is + // almost never the version the crate was linked against, and the failure it + // produces talks about schema numbers rather than what to do about it. + throw new Error( + `no wasm-bindgen ${wanted ?? '(version unknown)'} in ${cacheRoot}. ` + + 'Run `pnpm build` once so wasm-pack downloads the matching CLI, or ' + + `install it with \`cargo install wasm-bindgen-cli --version ${wanted ?? 'X.Y.Z'}\` ` + + 'and put it on PATH.' + ) } function build(variant) { From 233d021f9c750b836908fa6191f89dfd8c955482 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 18:37:51 -0300 Subject: [PATCH 54/71] fix(bridge): read legacy sender keys whose buffers are base64 text A stored sender key is legacy JSON, and the buffers inside it can be written two ways: as a byte array by plain JSON.stringify, or as base64 text by Baileys' own BufferJSON.replacer. The decoder only accepted the array, so a store that round-trips its rows through BufferJSON read every key as absent. Absent then defaulted to empty, which built a record with no chain seed and no signing key. That record fails to deserialize, and the failure surfaced as "protobuf encoding was invalid" with nothing pointing at the row it came from. Accept both shapes, and stop defaulting the fields the record cannot be built without so a bad row names itself instead of failing later. --- .../__tests__/Signal/group-sender-key.test.ts | 33 ++++++++++ .../src/storage_adapter.rs | 60 ++++++++++++++++--- 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts index 25fb41bbe43..79fdd6e0677 100644 --- a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts +++ b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts @@ -3,6 +3,7 @@ import P from 'pino' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { BufferJSON } from '../../Utils/generics' /** * Sending into a group starts with distributing your own sender key. Nothing @@ -93,6 +94,38 @@ describe('group sender keys', () => { expect(Buffer.from(received).toString()).toBe('hello group') }) + it('reads a stored key whose buffers came back as base64 text', async () => { + const alice = makeParty() + const bob = makeParty() + + const skdm = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + await bob.repository.processSenderKeyDistributionMessage({ + authorJid: aliceJid, + item: { groupId: groupJid, axolotlSenderKeyDistributionMessage: skdm } as never + }) + + // A store that round-trips its rows through BufferJSON writes every + // buffer as { type: 'Buffer', data: '' } instead of a byte + // array. Both shapes reach us, and reading one of them as absent would + // leave the record with empty keys and fail far from here. + const [key] = Object.keys(bob.data['sender-key']!) + const states = JSON.parse(Buffer.from(bob.data['sender-key']![key!] as Uint8Array).toString()) + bob.data['sender-key']![key!] = Buffer.from(JSON.stringify(states, BufferJSON.replacer)) + + const sent = await alice.repository.encryptGroupMessage({ + group: groupJid, + meId: aliceJid, + data: Buffer.from('hello group') + }) + const received = await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: sent.ciphertext + }) + + expect(Buffer.from(received).toString()).toBe('hello group') + }) + it('lets two members each distribute and send', async () => { const alice = makeParty() const bob = makeParty() diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 0c804dee4e4..60ac56fb01f 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -491,13 +491,15 @@ impl JsStorageAdapter { let sender_chain_key_obj = get_object(&state_obj, "senderChainKey") .ok_or_else(|| invalid_js_data("migrate_sender_key", "Missing senderChainKey"))?; let iteration = get_number(&sender_chain_key_obj, "iteration").unwrap_or(0.0) as u32; - let seed = - get_bytes_from_buffer_json(&sender_chain_key_obj, "seed")?.unwrap_or_default(); + // Defaulting a required key to empty produces a record that only + // fails much later, with an error that says nothing about the row + // it came from. Name it here instead. + let seed = required_key_bytes(&sender_chain_key_obj, "seed", "senderChainKey.seed")?; let sender_signing_key_obj = get_object(&state_obj, "senderSigningKey") .ok_or_else(|| invalid_js_data("migrate_sender_key", "Missing senderSigningKey"))?; let public_key = - get_bytes_from_buffer_json(&sender_signing_key_obj, "public")?.unwrap_or_default(); + required_key_bytes(&sender_signing_key_obj, "public", "senderSigningKey.public")?; // The JS backend wrote an empty Buffer for a state it has no private // key for, which is every sender key received from someone else. An // empty buffer is not a key: carrying it as Some() makes the record @@ -514,7 +516,7 @@ impl JsStorageAdapter { let msg_key_obj = sender_message_keys_arr.get(j); let msg_iteration = get_number(&msg_key_obj, "iteration").unwrap_or(0.0) as u32; let msg_seed = - get_bytes_from_buffer_json(&msg_key_obj, "seed")?.unwrap_or_default(); + required_key_bytes(&msg_key_obj, "seed", "senderMessageKeys[].seed")?; sender_message_keys.push(SenderMessageKey { iteration: Some(msg_iteration), @@ -773,6 +775,13 @@ fn get_number(obj: &JsValue, key: &str) -> Option { .and_then(|v| v.as_f64()) } +/// A key field the record cannot be built without. +fn required_key_bytes(obj: &JsValue, key: &str, label: &'static str) -> SignalResult> { + get_bytes_from_buffer_json(obj, key)? + .filter(|bytes| !bytes.is_empty()) + .ok_or_else(|| invalid_js_data("migrate_sender_key", label)) +} + fn get_bytes_from_buffer_json(obj: &JsValue, key: &str) -> SignalResult>> { let Ok(val) = js_sys::Reflect::get(obj, &JsValue::from_str(key)) else { return Ok(None); @@ -781,14 +790,19 @@ fn get_bytes_from_buffer_json(obj: &JsValue, key: &str) -> SignalResult Date: Wed, 5 Aug 2026 18:48:38 -0300 Subject: [PATCH 55/71] fix(bridge): write legacy sender-key states oldest first The core keeps the current state at the front of the record and prunes from the back. The legacy shape is the mirror of that: the JS backend reads the last entry as the current state and drops the first one when the record overflows. Both conversions carried the core order through unchanged, so a record with more than one state was written upside down. Rolling back to a pre-WASM release would send under a stale key, and the sixth distribution message would evict the freshest state instead of the oldest. Reverse on the way out and on the way back in. --- .../__tests__/Signal/group-sender-key.test.ts | 34 +++++++++++++++++++ .../src/storage_adapter.rs | 7 ++++ 2 files changed, 41 insertions(+) diff --git a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts index 79fdd6e0677..aa923763b93 100644 --- a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts +++ b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts @@ -126,6 +126,40 @@ describe('group sender keys', () => { expect(Buffer.from(received).toString()).toBe('hello group') }) + it('stores rotated states oldest first, the order the JS backend reads', async () => { + const bob = makeParty() + const statesOf = () => { + const [key] = Object.keys(bob.data['sender-key']!) + return JSON.parse(Buffer.from(bob.data['sender-key']![key!] as Uint8Array).toString()) as { + senderKeyId: number + }[] + } + + const distribute = async () => { + const sender = makeParty() + const skdm = await sender.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + await bob.repository.processSenderKeyDistributionMessage({ + authorJid: aliceJid, + item: { groupId: groupJid, axolotlSenderKeyDistributionMessage: skdm } as never + }) + } + + const seen: number[] = [] + for (let round = 0; round < 3; round++) { + await distribute() + // Reading after each round also exercises the import: from the second + // one on, the bridge parses the row it wrote and has to recover the + // same order it will write back. + seen.push(statesOf().at(-1)!.senderKeyId) + } + + // The JS backend takes the LAST entry as the current state and drops the + // FIRST one on overflow. The core keeps the newest in front, so writing + // its order out unchanged would make a rollback pick the stale key and + // evict the freshest one. + expect(statesOf().map(state => state.senderKeyId)).toEqual(seen) + }) + it('lets two members each distribute and send', async () => { const alice = makeParty() const bob = makeParty() diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 60ac56fb01f..b99e7d6432a 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -542,6 +542,8 @@ impl JsStorageAdapter { }); } + // Mirror of the order note in `legacy_sender_key::serialize`. + sender_key_states.reverse(); let record = SenderKeyRecordStructure { sender_key_states }; Ok(Some(record.encode_to_vec())) @@ -1242,9 +1244,14 @@ pub mod legacy_sender_key { pub fn serialize(record: CoreSenderKeyRecord) -> SignalResult> { let components = record.into_components()?; + // The core keeps the current state in front and prunes from the back; + // the legacy shape is the mirror of that, current last and pruned from + // the front. Writing the core order out unchanged would make a pre-WASM + // build send under a stale key and evict the freshest one. let states: Vec> = components .states .iter() + .rev() .map(|state| State { sender_key_id: state.key_id, sender_chain_key: ChainKey { From fdeb5c38a84c959fb7390132d66d0485ecef4015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 18:48:38 -0300 Subject: [PATCH 56/71] fix(bridge): resolve wasm-bindgen from PATH in profiling builds The error told the reader to install the exact wasm-bindgen-cli and put it on PATH, but the lookup only scanned the wasm-pack cache. Following that advice on a clean machine hit the same throw, leaving build:profile unusable until an unrelated normal build populated the cache. Scan PATH after the cache, with the same exact-version check, so a mismatched global build still cannot slip in. --- .../scripts/build-wasm.mjs | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs index ba3a9c929b2..0394d034066 100644 --- a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs +++ b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process' import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { delimiter, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -59,14 +59,31 @@ function wasmBindgen() { /name = "wasm-bindgen"\nversion = "([^"]+)"/ )?.[1] + const matches = candidate => { + if (!existsSync(candidate)) return false + + const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }).stdout?.trim() + return Boolean(wanted) && Boolean(version?.endsWith(wanted)) + } + const cacheRoot = resolve(process.env.HOME ?? '', '.cache/.wasm-pack') if (wanted && existsSync(cacheRoot)) { for (const entry of readdirSync(cacheRoot)) { const candidate = resolve(cacheRoot, entry, 'wasm-bindgen') - if (!existsSync(candidate)) continue + if (matches(candidate)) return candidate + } + } + + // Then PATH, which is the other remedy the error below names. Only an exact + // version match is accepted, so this cannot silently reintroduce the schema + // mismatch that made the cache the first choice. + const extensions = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''] + for (const directory of (process.env.PATH ?? '').split(delimiter)) { + if (!directory) continue - const version = spawnSync(candidate, ['--version'], { encoding: 'utf8' }).stdout?.trim() - if (version?.endsWith(wanted)) return candidate + for (const extension of extensions) { + const candidate = resolve(directory, `wasm-bindgen${extension}`) + if (matches(candidate)) return candidate } } @@ -74,7 +91,7 @@ function wasmBindgen() { // almost never the version the crate was linked against, and the failure it // produces talks about schema numbers rather than what to do about it. throw new Error( - `no wasm-bindgen ${wanted ?? '(version unknown)'} in ${cacheRoot}. ` + + `no wasm-bindgen ${wanted ?? '(version unknown)'} in ${cacheRoot} or on PATH. ` + 'Run `pnpm build` once so wasm-pack downloads the matching CLI, or ' + `install it with \`cargo install wasm-bindgen-cli --version ${wanted ?? 'X.Y.Z'}\` ` + 'and put it on PATH.' From 734c0f6a304bf26f30ebd15ada106742f1db553e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 19:13:03 -0300 Subject: [PATCH 57/71] test(bridge): stop forcing a GC between benchmark iterations mitata already defaults to collecting once before a benchmark; asking for "inner" collects between every iteration instead. That penalises the WASM side out of proportion, because each collection also drains the FinalizationRegistry that frees wasm-bindgen handles, and the cost lands inside the measurement. The numbers were not just noisy, they pointed the wrong way: encodeNode read as 11.45x slower than the JS encoder and measures 2.24x faster without the forced collection, and decodeNode goes from 11.98x slower to 2.56x faster. --- .../whatsapp-rust-bridge/benches/appstate.ts | 32 +++++++++---------- .../whatsapp-rust-bridge/benches/binary.ts | 16 +++++----- .../benches/noise-session.ts | 28 ++++++++-------- .../whatsapp-rust-bridge/benches/signal.ts | 12 +++---- 4 files changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/whatsapp-rust-bridge/benches/appstate.ts b/packages/whatsapp-rust-bridge/benches/appstate.ts index 8ba491d1d17..05d5cba1f91 100644 --- a/packages/whatsapp-rust-bridge/benches/appstate.ts +++ b/packages/whatsapp-rust-bridge/benches/appstate.ts @@ -114,19 +114,19 @@ boxplot(() => { bench("Key Expansion (Rust WASM)", () => { const result = expandAppStateKeys(masterKey); do_not_optimize(result); - }).gc("inner"); + }); bench("Key Expansion (Baileys)", async () => { const result = await baileysExpandKeys(masterKey); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { bench("LT-Hash subtractThenAdd - 3 adds (Rust WASM)", () => { const result = ltHashWasm.subtractThenAdd(baseHash, [], addItems); do_not_optimize(result); - }).gc("inner"); + }); bench("LT-Hash subtractThenAdd - 3 adds (Baileys)", async () => { const result = await LT_HASH_ANTI_TAMPERING.subtractThenAdd( @@ -135,7 +135,7 @@ boxplot(() => { [] ); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { @@ -146,7 +146,7 @@ boxplot(() => { addItems ); do_not_optimize(result); - }).gc("inner"); + }); bench("LT-Hash subtractThenAdd - mixed ops (Baileys)", async () => { const result = await LT_HASH_ANTI_TAMPERING.subtractThenAdd( @@ -155,7 +155,7 @@ boxplot(() => { subtractItems.map((i) => i.buffer as ArrayBuffer) ); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { @@ -167,7 +167,7 @@ boxplot(() => { wasmKeys.valueMacKey ); do_not_optimize(result); - }).gc("inner"); + }); bench("Content MAC generation (Baileys)", () => { const result = baileysGenerateContentMac( @@ -177,19 +177,19 @@ boxplot(() => { baileysKeys.valueMacKey ); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { bench("Index MAC generation (Rust WASM)", () => { const result = generateIndexMac(indexBytes, wasmKeys.indexKey); do_not_optimize(result); - }).gc("inner"); + }); bench("Index MAC generation (Baileys)", () => { const result = hmacSign(indexBytes, baileysKeys.indexKey); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { @@ -201,7 +201,7 @@ boxplot(() => { wasmKeys.snapshotMacKey ); do_not_optimize(result); - }).gc("inner"); + }); bench("Snapshot MAC generation (Baileys)", () => { const result = baileysGenerateSnapshotMac( @@ -211,7 +211,7 @@ boxplot(() => { baileysKeys.snapshotMacKey ); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { @@ -224,7 +224,7 @@ boxplot(() => { wasmKeys.patchMacKey ); do_not_optimize(result); - }).gc("inner"); + }); bench("Patch MAC generation (Baileys)", () => { const result = baileysGeneratePatchMac( @@ -235,7 +235,7 @@ boxplot(() => { baileysKeys.patchMacKey ); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { @@ -268,7 +268,7 @@ boxplot(() => { do_not_optimize(newHash); do_not_optimize(snapMac); do_not_optimize(patchMac); - }).gc("inner"); + }); bench("Full mutation encode flow (Baileys)", async () => { const keys = await baileysExpandKeys(masterKey); @@ -303,7 +303,7 @@ boxplot(() => { do_not_optimize(newHash); do_not_optimize(snapMac); do_not_optimize(patchMac); - }).gc("inner"); + }); }); }); diff --git a/packages/whatsapp-rust-bridge/benches/binary.ts b/packages/whatsapp-rust-bridge/benches/binary.ts index b1696da8a5c..2f92a7f0d2e 100644 --- a/packages/whatsapp-rust-bridge/benches/binary.ts +++ b/packages/whatsapp-rust-bridge/benches/binary.ts @@ -79,48 +79,48 @@ boxplot(() => { bench("encodeNode Rust WASM", () => { const result = encodeNode(testNode); do_not_optimize(result); - }).gc("inner"); + }); bench("encodeNode Old Baileys", () => { const result = encodeBinaryNodeOld(testNode); do_not_optimize(result); - }).gc("inner"); + }); }); summary(() => { bench("decodeNode Rust WASM", () => { const handle = decodeNode(legacyEncoded); do_not_optimize(handle); - }).gc("inner"); + }); bench("decodeNode Old Baileys", async () => { const handle = await decodeBinaryNodeOld(legacyEncoded); do_not_optimize(handle); - }).gc("inner"); + }); }); summary(() => { bench("decode and attrs Rust WASM", () => { const handle = decodeNode(legacyEncoded); touchHotPath(handle); - }).gc("inner"); + }); bench("decode and attrs Old Baileys", async () => { const handle = await decodeBinaryNodeOld(legacyEncoded); touchHotPath(handle); - }).gc("inner"); + }); }); summary(() => { bench("decode and attrs (compressed) Rust WASM", () => { const handle = decodeNode(compressedEncoded); touchHotPath(handle); - }).gc("inner"); + }); bench("decode and attrs (compressed) Old Baileys", async () => { const handle = await decodeBinaryNodeOld(compressedEncoded); touchHotPath(handle); - }).gc("inner"); + }); }); }); diff --git a/packages/whatsapp-rust-bridge/benches/noise-session.ts b/packages/whatsapp-rust-bridge/benches/noise-session.ts index 77f097c55db..22f08851e3c 100644 --- a/packages/whatsapp-rust-bridge/benches/noise-session.ts +++ b/packages/whatsapp-rust-bridge/benches/noise-session.ts @@ -119,12 +119,12 @@ boxplot(() => { undefined, ); do_not_optimize(session); - }).gc("inner"); + }); bench("NoiseSession JS - constructor", () => { const session = new JSNoiseSession(testPublicKey, testNoiseHeader); do_not_optimize(session); - }).gc("inner"); + }); }); }); @@ -137,13 +137,13 @@ boxplot(() => { wasmSession = new NoiseSession(testPublicKey, testNoiseHeader, undefined); const result = wasmSession.encrypt(testPlaintext); do_not_optimize(result); - }).gc("inner"); + }); bench("NoiseSession JS - encrypt (256 bytes)", () => { jsSession = new JSNoiseSession(testPublicKey, testNoiseHeader); const result = jsSession.encrypt(testPlaintext); do_not_optimize(result); - }).gc("inner"); + }); }); }); @@ -157,13 +157,13 @@ boxplot(() => { ); const result = session.encodeFrameRaw(testPlaintext); do_not_optimize(result); - }).gc("inner"); + }); bench("NoiseSession JS - encodeFrameRaw (no encryption)", () => { const session = new JSNoiseSession(testPublicKey, testNoiseHeader); const result = session.encodeFrameRaw(testPlaintext); do_not_optimize(result); - }).gc("inner"); + }); }); }); @@ -177,13 +177,13 @@ boxplot(() => { ); const result = session.encodeFrame(testNode); do_not_optimize(result); - }).gc("inner"); + }); bench("NoiseSession JS - encodeFrame (separate ops)", () => { const session = new JSNoiseSession(testPublicKey, testNoiseHeader); const result = session.encodeFrame(testNode); do_not_optimize(result); - }).gc("inner"); + }); }); }); @@ -199,14 +199,14 @@ boxplot(() => { session.finishInit(); const result = session.encodeFrame(testNode); do_not_optimize(result); - }).gc("inner"); + }); bench("NoiseSession JS - encodeFrame after finishInit", async () => { const session = new JSNoiseSession(testPublicKey, testNoiseHeader); await session.finishInit(); const result = session.encodeFrame(testNode); do_not_optimize(result); - }).gc("inner"); + }); }); }); @@ -226,7 +226,7 @@ boxplot(() => { undefined, ); session.decodeFrame(frameData); - }).gc("inner"); + }); bench("Buffer parsing JS - decodeFrame equivalent", () => { let inBytes = Buffer.alloc(0); @@ -234,7 +234,7 @@ boxplot(() => { const size = (inBytes.readUInt8() << 16) | inBytes.readUInt16BE(1); const frame = inBytes.slice(3, size + 3); do_not_optimize(frame); - }).gc("inner"); + }); }); }); @@ -251,7 +251,7 @@ boxplot(() => { const result = session.encrypt(testPlaintext); do_not_optimize(result); } - }).gc("inner"); + }); bench("NoiseSession JS - 10 encrypts", () => { const session = new JSNoiseSession(testPublicKey, testNoiseHeader); @@ -259,7 +259,7 @@ boxplot(() => { const result = session.encrypt(testPlaintext); do_not_optimize(result); } - }).gc("inner"); + }); }); }); diff --git a/packages/whatsapp-rust-bridge/benches/signal.ts b/packages/whatsapp-rust-bridge/benches/signal.ts index c58a507dce0..2c50ef667d5 100644 --- a/packages/whatsapp-rust-bridge/benches/signal.ts +++ b/packages/whatsapp-rust-bridge/benches/signal.ts @@ -220,12 +220,12 @@ boxplot(() => { bench("Encrypt typical message (Rust WASM)", async () => { const result = await encWasm.alice.encrypt(typicalMessage); do_not_optimize(result); - }).gc("inner"); + }); bench("Encrypt typical message (libsignal-node)", async () => { const result = await encLib.alice.encrypt(typicalMessage); do_not_optimize(result); - }).gc("inner"); + }); }); }); @@ -249,7 +249,7 @@ boxplot(() => { do_not_optimize(result); }, }; - }).gc("inner"); + }); bench("Decrypt WhisperMessage (libsignal-node)", function* () { yield { @@ -261,7 +261,7 @@ boxplot(() => { do_not_optimize(result); }, }; - }).gc("inner"); + }); }); }); @@ -282,7 +282,7 @@ boxplot(() => { ); do_not_optimize(decryptedByBob); do_not_optimize(decryptedByAlice); - }).gc("inner"); + }); bench("Full round-trip encrypt+decrypt (libsignal-node)", async () => { const toBob = await rtLib.alice.encrypt(typicalMessage); @@ -295,7 +295,7 @@ boxplot(() => { ); do_not_optimize(decryptedByBob); do_not_optimize(decryptedByAlice); - }).gc("inner"); + }); }); }); From a1deea87768690c4908454442dd242be04dedede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 19:41:39 -0300 Subject: [PATCH 58/71] fix(bridge): waive the core's counter lease The core leases 64 outbound counters ahead of durability so the send path only needs a flush once per batch. Carrying that ceiling forward means persisting it, and the record shape we write has nowhere to put it: the component export materializes the reservation, so the whole batch burned on every operation instead of once per batch. Consecutive sends landed on the wire at counters 0, 64, 128, 192, and the peer derived 63 skipped keys for each one. After 20 group messages the stored row was 199,724 bytes and a decrypt cost 3,488 us against 97 us for the first; a DM row passed MAX_LEGACY_MESSAGE_KEYS around the 32nd message and fell back to bridge bytes, which is the rollback guarantee going away. We hand the changeset back to the caller, which persists it before the ciphertext reaches the wire, so there is nothing left for the lease to protect. Every record goes through counter_lease.rs rather than calling the core directly: a path that forgets goes back to leasing without failing, and the symptom shows up somewhere else entirely. Group decrypt is now flat at ~82 us with a 441 byte row, and a DM row after 40 messages is 919 bytes and still legacy. --- .../__tests__/Signal/counter-lease.test.ts | 231 ++++++++++++++++++ packages/whatsapp-rust-bridge/Cargo.lock | 12 +- .../whatsapp-rust-bridge/src/counter_lease.rs | 32 +++ .../whatsapp-rust-bridge/src/group_types.rs | 12 +- packages/whatsapp-rust-bridge/src/lib.rs | 1 + .../src/session_builder.rs | 4 +- .../whatsapp-rust-bridge/src/snapshot_api.rs | 4 +- .../src/snapshot_store.rs | 7 +- .../src/storage_adapter.rs | 5 +- 9 files changed, 292 insertions(+), 16 deletions(-) create mode 100644 packages/baileys/src/__tests__/Signal/counter-lease.test.ts create mode 100644 packages/whatsapp-rust-bridge/src/counter_lease.rs diff --git a/packages/baileys/src/__tests__/Signal/counter-lease.test.ts b/packages/baileys/src/__tests__/Signal/counter-lease.test.ts new file mode 100644 index 00000000000..79d5f7e5773 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/counter-lease.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { makeLibSignalRepository } from '../../Signal/libsignal' +import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { Curve, generateSignalPubKey } from '../../Utils/crypto' + +/** + * The core leases outbound counters ahead of durability, and the record shape + * we persist has nowhere to carry that ceiling: every export materializes the + * reservation, so each send would jump a whole batch. The bridge waives the + * lease because it hands the changeset back before the ciphertext is sent. + * + * These cases read the counter off the wire rather than the stored record: it + * is the counter the peer has to follow, and a record that looks consecutive + * while the wire jumps would still strand the conversation. + */ +const logger = P({ level: 'silent' }) + +const makeParty = () => { + const creds = initAuthCreds() + const data: { [type: string]: { [id: string]: unknown } } = {} + const store: SignalKeyStore = { + get: async (type, ids) => { + const bucket = data[type] || {} + const out: { [id: string]: SignalDataTypeMap[typeof type] } = {} + for (const id of ids) { + if (bucket[id] !== undefined && bucket[id] !== null) out[id] = bucket[id] as never + } + + return out + }, + set: async (update: SignalDataSet) => { + for (const type of Object.keys(update)) { + data[type] ||= {} + const bucket = update[type as keyof SignalDataSet]! + for (const id of Object.keys(bucket)) { + const value = (bucket as Record)[id] + if (value === null) delete data[type]![id] + else data[type]![id] = value + } + } + } + } + + const auth: SignalAuthState = { + creds, + keys: addTransactionCapability(store, logger, { maxCommitRetries: 1, delayBetweenTriesMs: 1 }) + } + + return { auth, creds, data, repository: makeLibSignalRepository(auth, logger) } +} + +/** Length-delimited field of a protobuf carrying a leading version byte. */ +const field = (buf: Uint8Array, want: number): Uint8Array | undefined => { + let i = 1 + while (i < buf.length) { + const tag = buf[i++]! + if ((tag & 7) === 2) { + let len = 0 + let shift = 0 + for (;;) { + const byte = buf[i++]! + len |= (byte & 0x7f) << shift + shift += 7 + if (!(byte & 0x80)) break + } + + if (tag >> 3 === want) return buf.subarray(i, i + len) + i += len + } else if ((tag & 7) === 0) { + while (buf[i++]! & 0x80); + } else return undefined + } + + return undefined +} + +/** WhisperMessage.counter, field 2. */ +const counterOf = (ciphertext: Uint8Array): number => { + let i = 1 + while (i < ciphertext.length) { + const tag = ciphertext[i++]! + if ((tag & 7) === 0) { + let value = 0 + let shift = 0 + for (;;) { + const byte = ciphertext[i++]! + value |= (byte & 0x7f) << shift + shift += 7 + if (!(byte & 0x80)) break + } + + if (tag >> 3 === 2) return value + } else if ((tag & 7) === 2) { + let len = 0 + let shift = 0 + for (;;) { + const byte = ciphertext[i++]! + len |= (byte & 0x7f) << shift + shift += 7 + if (!(byte & 0x80)) break + } + + i += len + } else return -1 + } + + return -1 +} + +const aliceJid = '1111111111@s.whatsapp.net' +const bobJid = '2222222222@s.whatsapp.net' +const groupJid = '120363000000000001@g.us' + +const establish = async (alice: ReturnType, bob: ReturnType) => { + const preKey = Curve.generateKeyPair() + await bob.auth.keys.set({ 'pre-key': { 1: preKey } }) + await alice.repository.injectE2ESession({ + jid: bobJid, + session: { + registrationId: bob.creds.registrationId, + identityKey: generateSignalPubKey(bob.creds.signedIdentityKey.public), + preKey: { keyId: 1, publicKey: generateSignalPubKey(preKey.public) }, + signedPreKey: { + keyId: bob.creds.signedPreKey.keyId, + publicKey: generateSignalPubKey(bob.creds.signedPreKey.keyPair.public), + signature: bob.creds.signedPreKey.signature + } + } as never + }) +} + +describe('outbound counters across operations', () => { + it('advances one per message instead of a batch', async () => { + const alice = makeParty() + const bob = makeParty() + await establish(alice, bob) + + const counters: number[] = [] + for (let index = 0; index < 6; index++) { + const { ciphertext, type } = await alice.repository.encryptMessage({ + jid: bobJid, + data: Buffer.from(`m${index}`) + }) + // Every send is its own operation, which is what used to burn a batch. + counters.push(counterOf(type === 'pkmsg' ? field(ciphertext, 4)! : ciphertext)) + } + + expect(counters).toEqual([0, 1, 2, 3, 4, 5]) + }) + + it('leaves the peer with no skipped keys to buffer', async () => { + const alice = makeParty() + const bob = makeParty() + await establish(alice, bob) + + for (let index = 0; index < 6; index++) { + const message = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`m${index}`) }) + const plaintext = await bob.repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: message.ciphertext + }) + expect(Buffer.from(plaintext).toString()).toBe(`m${index}`) + } + + // A batch-sized gap made the peer derive 63 keys per message, which is + // what grew the row until it no longer fit the legacy shape. + const record = bob.data.session!['1111111111.0'] as { _sessions: Record } + const skipped = Object.values(record._sessions).flatMap(session => + Object.values((session as { _chains: Record })._chains).map( + chain => Object.keys(chain.messageKeys ?? {}).length + ) + ) + + expect(Math.max(...skipped)).toBe(0) + }) + + it('advances group iterations one per message', async () => { + const alice = makeParty() + const bob = makeParty() + + const skdm = await alice.repository.getSenderKeyDistributionMessage({ group: groupJid, meId: aliceJid }) + await bob.repository.processSenderKeyDistributionMessage({ + authorJid: aliceJid, + item: { groupId: groupJid, axolotlSenderKeyDistributionMessage: skdm } as never + }) + + for (let index = 0; index < 6; index++) { + const sent = await alice.repository.encryptGroupMessage({ + group: groupJid, + meId: aliceJid, + data: Buffer.from(`g${index}`) + }) + const plaintext = await bob.repository.decryptGroupMessage({ + group: groupJid, + authorJid: aliceJid, + msg: sent.ciphertext + }) + expect(Buffer.from(plaintext).toString()).toBe(`g${index}`) + } + + const [key] = Object.keys(alice.data['sender-key']!) + const [state] = JSON.parse(Buffer.from(alice.data['sender-key']![key!] as Uint8Array).toString()) + + expect(state.senderChainKey.iteration).toBe(6) + }) + + it('keeps the stored row in the legacy shape as the conversation runs', async () => { + const alice = makeParty() + const bob = makeParty() + await establish(alice, bob) + + for (let index = 0; index < 40; index++) { + const message = await alice.repository.encryptMessage({ jid: bobJid, data: Buffer.from(`m${index}`) }) + await bob.repository.decryptMessage({ + jid: aliceJid, + type: message.type as 'msg' | 'pkmsg', + ciphertext: message.ciphertext + }) + } + + // With the lease materialised this row passed MAX_LEGACY_MESSAGE_KEYS + // around the 32nd message and fell back to bridge bytes, which is the + // rollback guarantee going away. + const stored = bob.data.session!['1111111111.0'] + expect(ArrayBuffer.isView(stored)).toBe(false) + expect(JSON.stringify(stored).length).toBeLessThan(20_000) + }) +}) diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index f3a1bafe1fe..05de843fe2a 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -1406,7 +1406,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore-appstate" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "anyhow", "buffa", @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "wacore-binary" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "bytes", "compact_str", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "wacore-derive" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "proc-macro2", "quote", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "wacore-libsignal" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "aes", "async-lock", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "wacore-noise" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "anyhow", "buffa", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "waproto" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#37ae4107d7b6657509c8813747a2c2a371ca6568" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" dependencies = [ "buffa", "buffa-build", diff --git a/packages/whatsapp-rust-bridge/src/counter_lease.rs b/packages/whatsapp-rust-bridge/src/counter_lease.rs new file mode 100644 index 00000000000..b4bfd015e01 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/counter_lease.rs @@ -0,0 +1,32 @@ +//! Every record this crate puts into service waives the core's counter lease. +//! +//! The lease reserves a batch of outbound counters ahead of durability so the +//! send path only needs a flush once per batch. Carrying that ceiling forward +//! requires persisting it, and the record shape this crate writes has nowhere +//! to put it: the export materializes the reservation, so the whole batch burns +//! on every operation instead of once per batch. Left alone, consecutive sends +//! land on the wire 64 counters apart and the peer buffers 63 skipped keys for +//! each one. +//! +//! What the lease protects against is a crash between the encrypt and the +//! write. This crate hands the changeset back to the caller, which persists it +//! before the ciphertext reaches the wire, so there is nothing left for the +//! lease to protect. That is the trade the waiver states, and it is the same +//! guarantee the pre-WASM releases gave. +//! +//! Route every record through here rather than calling the core directly: a +//! path that forgets goes back to leasing without failing, and the only symptom +//! is skipped keys piling up somewhere else. + +use wacore_libsignal::protocol::error::Result as SignalResult; +use wacore_libsignal::protocol::{SenderKeyRecord, SessionRecord}; + +pub(crate) fn waive_session(mut record: SessionRecord) -> SessionRecord { + record.waive_counter_lease(); + record +} + +pub(crate) fn waive_sender_key(mut record: SenderKeyRecord) -> SignalResult { + record.waive_counter_lease()?; + Ok(record) +} diff --git a/packages/whatsapp-rust-bridge/src/group_types.rs b/packages/whatsapp-rust-bridge/src/group_types.rs index 491ec4665f3..ab2d5b1bede 100644 --- a/packages/whatsapp-rust-bridge/src/group_types.rs +++ b/packages/whatsapp-rust-bridge/src/group_types.rs @@ -16,9 +16,10 @@ pub struct SenderKeyRecord { impl Default for SenderKeyRecord { fn default() -> Self { - Self { - core: CoreSenderKeyRecord::new_empty(), - } + let mut core = CoreSenderKeyRecord::new_empty(); + // A fresh record never reaches the adapter's load path, so it waives here. + let _ = core.waive_counter_lease(); + Self { core } } } @@ -31,7 +32,10 @@ impl SenderKeyRecord { #[wasm_bindgen(js_name = deserialize)] pub fn deserialize(serialized: &[u8]) -> Result { - let core = CoreSenderKeyRecord::deserialize(serialized).map_err(map_err)?; + let core = crate::counter_lease::waive_sender_key( + CoreSenderKeyRecord::deserialize(serialized).map_err(map_err)?, + ) + .map_err(map_err)?; Ok(Self { core }) } diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index 346ee1ab983..67002207b83 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -2,6 +2,7 @@ pub mod appstate; #[cfg(feature = "audio")] pub mod audio; pub mod binary; +mod counter_lease; pub mod crypto; pub mod curve; pub mod group_cipher; diff --git a/packages/whatsapp-rust-bridge/src/session_builder.rs b/packages/whatsapp-rust-bridge/src/session_builder.rs index 94861ca6d82..107569cfd3b 100644 --- a/packages/whatsapp-rust-bridge/src/session_builder.rs +++ b/packages/whatsapp-rust-bridge/src/session_builder.rs @@ -22,7 +22,9 @@ impl SessionRecord { } pub fn to_core(&self) -> Result { - CoreSessionRecord::deserialize(&self.serialized_data) + Ok(crate::counter_lease::waive_session( + CoreSessionRecord::deserialize(&self.serialized_data)?, + )) } } diff --git a/packages/whatsapp-rust-bridge/src/snapshot_api.rs b/packages/whatsapp-rust-bridge/src/snapshot_api.rs index 4504dcd002f..3f590be40e7 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_api.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_api.rs @@ -202,7 +202,9 @@ fn build_store(snapshot: SignalSnapshot) -> Result { if let Some(bytes) = snapshot.sender_key { let record = SenderKeyRecord::deserialize(bytes.as_ref()) .map_err(|e| err("snapshot.senderKey", e))?; - store.with_sender_key(record); + store + .with_sender_key(record) + .map_err(|e| err("snapshot.senderKey", e))?; } Ok(store) diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index 494dad841ef..52f32129e68 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -73,7 +73,7 @@ impl SnapshotStore { } pub fn with_session(&self, record: SessionRecord) { - self.inner.borrow_mut().session = Some(record); + self.inner.borrow_mut().session = Some(crate::counter_lease::waive_session(record)); } pub fn with_peer_identity_bytes(&self, identity: Vec) { @@ -88,8 +88,9 @@ impl SnapshotStore { self.inner.borrow_mut().signed_pre_keys.insert(id, record); } - pub fn with_sender_key(&self, record: SenderKeyRecord) { - self.inner.borrow_mut().sender_key = Some(record); + pub fn with_sender_key(&self, record: SenderKeyRecord) -> SignalResult<()> { + self.inner.borrow_mut().sender_key = Some(crate::counter_lease::waive_sender_key(record)?); + Ok(()) } pub fn take_changes(&self) -> SnapshotChanges { diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index b99e7d6432a..8285a364b87 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -857,7 +857,8 @@ impl SessionStore for JsStorageAdapter { match bytes { Some(data) => { - let record = CoreSessionRecord::deserialize(&data)?; + let record = + crate::counter_lease::waive_session(CoreSessionRecord::deserialize(&data)?); // Insert into cache and return a clone - this is required since HashMap takes ownership let result = record.clone(); self.cached_sessions @@ -1155,6 +1156,8 @@ impl SenderKeyStore for JsStorageAdapter { } }; + let record = crate::counter_lease::waive_sender_key(record)?; + self.cached_sender_keys .borrow_mut() .insert(key_id, record.clone()); From c06ab3f5cd9e006f60f229bd54fee6bb0d85d4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 20:09:33 -0300 Subject: [PATCH 59/71] fix(bridge): waive the counter lease on the store paths too Records were waived where they are loaded, but a record the protocol builds for itself never passes a load: the store path is the only place that sees it before it is serialized and cached. Both stores kept the default lease on such a record, so the policy read differently depending on where a record came from. No such record can reach a send today, because the builders and the ciphers hold separate adapters and therefore separate caches. Verified: five sends through one reused cipher, over a record the group builder created, leave the iteration at 5 either way. That is a property of the call sites though, not of the policy, and the whole point of routing records through one place is that a path which forgets fails silently. Also pin the shape in the base64 sender-key test. It rewrites the row with BufferJSON.replacer, which rewrites a { type: 'Buffer', data: [...] } object as well as a real Buffer, so the assertion now states that rather than leaving a reader to work it out. --- .../src/__tests__/Signal/group-sender-key.test.ts | 11 ++++++++++- packages/whatsapp-rust-bridge/src/counter_lease.rs | 7 +++++++ packages/whatsapp-rust-bridge/src/snapshot_store.rs | 5 +++++ packages/whatsapp-rust-bridge/src/storage_adapter.rs | 6 ++++++ 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts index aa923763b93..fe4b2815f8d 100644 --- a/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts +++ b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts @@ -110,7 +110,16 @@ describe('group sender keys', () => { // leave the record with empty keys and fail far from here. const [key] = Object.keys(bob.data['sender-key']!) const states = JSON.parse(Buffer.from(bob.data['sender-key']![key!] as Uint8Array).toString()) - bob.data['sender-key']![key!] = Buffer.from(JSON.stringify(states, BufferJSON.replacer)) + const rewritten = JSON.stringify(states, BufferJSON.replacer) + bob.data['sender-key']![key!] = Buffer.from(rewritten) + + // The replacer also rewrites a { type: 'Buffer', data: [...] } object, + // which is what JSON.parse leaves behind, so pin the shape rather than + // trusting that it produced one. + expect(JSON.parse(rewritten)[0].senderChainKey.seed).toEqual({ + type: 'Buffer', + data: expect.any(String) + }) const sent = await alice.repository.encryptGroupMessage({ group: groupJid, diff --git a/packages/whatsapp-rust-bridge/src/counter_lease.rs b/packages/whatsapp-rust-bridge/src/counter_lease.rs index b4bfd015e01..41bf0584056 100644 --- a/packages/whatsapp-rust-bridge/src/counter_lease.rs +++ b/packages/whatsapp-rust-bridge/src/counter_lease.rs @@ -17,6 +17,13 @@ //! Route every record through here rather than calling the core directly: a //! path that forgets goes back to leasing without failing, and the only symptom //! is skipped keys piling up somewhere else. +//! +//! Records are waived on the way out as well as on the way in. A record the +//! protocol built for itself never passed a load, so the store path is the only +//! place that sees it before it is serialized and cached. No such record can +//! reach a send today, because the builders and the ciphers hold separate +//! adapters and therefore separate caches, but that is a property of the call +//! sites rather than of this policy. use wacore_libsignal::protocol::error::Result as SignalResult; use wacore_libsignal::protocol::{SenderKeyRecord, SessionRecord}; diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index 52f32129e68..e867923f2c5 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -116,6 +116,9 @@ impl SessionStore for SnapshotStore { _address: &ProtocolAddress, record: SessionRecord, ) -> SignalResult<()> { + // A record the protocol built for itself never passed `with_session`, + // so this is the only place that sees it before it is serialized. + let record = crate::counter_lease::waive_session(record); let bytes = record.serialize()?; let mut inner = self.inner.borrow_mut(); inner.changes.session = Some(bytes); @@ -253,6 +256,8 @@ impl SenderKeyStore for SnapshotStore { _sender_key_name: &CoreSenderKeyName, record: SenderKeyRecord, ) -> SignalResult<()> { + // Mirror of the note in `store_session`. + let record = crate::counter_lease::waive_sender_key(record)?; let bytes = record.serialize()?; let mut inner = self.inner.borrow_mut(); inner.changes.sender_key = Some(bytes); diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 8285a364b87..ef283227c9b 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -881,6 +881,10 @@ impl SessionStore for JsStorageAdapter { ) -> SignalResult<()> { let address_str = self.get_address_string(address); + // Before serializing: a record the protocol built for itself never + // passed the load path, and neither the row nor the cache should carry + // a reservation this crate does not honour. + let record = crate::counter_lease::waive_session(record); let bytes = record.serialize()?; let result = if self.has_store_session_raw() { @@ -1171,6 +1175,8 @@ impl SenderKeyStore for JsStorageAdapter { ) -> SignalResult<()> { let key_id = self.get_sender_key_id(sender_key_name); + // Mirror of the note in `store_session`. + let record = crate::counter_lease::waive_sender_key(record)?; let bytes = record.serialize()?; let uint8 = Uint8Array::from(bytes.as_slice()); From e8bd646b145ea8a7d02c41c5272d237842939704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 22:28:49 -0300 Subject: [PATCH 60/71] perf(bridge): keep the sender-key derivation the record cannot keep A sender-key state memoizes the signing key's basepoint multiplication and the verifier's Edwards entries, but the memo lives in the record and we rebuild the record from storage on every operation. Every group message repaid a derivation whose inputs never changed. Keeping the record alive instead is the obvious fix and the wrong one: it would go stale the moment anything rotated the key, and encrypting under a chain the peer already dropped is worse than encrypting slowly. Keying on the signing key's public bytes has no such window. A rotated key is a different entry by construction, so a stale entry is unreachable rather than wrong, and the core's prewarm setters verify that what they are handed belongs to the state before installing it. Measured on the group path, cipher rebuilt per message against one kept warm: the gap closes from 14.8 to 1.9 us on encrypt and from 10.2 to 5.9 on decrypt. End to end, encryptGroupMessage goes from 71.8 to 62.0 us. Needs oxidezap/whatsapp-rust#1213, which added the setters. --- packages/whatsapp-rust-bridge/Cargo.lock | 12 +- .../src/derivation_cache.rs | 183 ++++++++++++++++++ packages/whatsapp-rust-bridge/src/lib.rs | 1 + .../src/snapshot_store.rs | 5 +- .../src/storage_adapter.rs | 2 + 5 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 packages/whatsapp-rust-bridge/src/derivation_cache.rs diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index 05de843fe2a..8bb409d6f12 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -1406,7 +1406,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wacore-appstate" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "anyhow", "buffa", @@ -1427,7 +1427,7 @@ dependencies = [ [[package]] name = "wacore-binary" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "bytes", "compact_str", @@ -1444,7 +1444,7 @@ dependencies = [ [[package]] name = "wacore-derive" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "proc-macro2", "quote", @@ -1454,7 +1454,7 @@ dependencies = [ [[package]] name = "wacore-libsignal" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "aes", "async-lock", @@ -1485,7 +1485,7 @@ dependencies = [ [[package]] name = "wacore-noise" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "anyhow", "buffa", @@ -1513,7 +1513,7 @@ dependencies = [ [[package]] name = "waproto" version = "0.6.0" -source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#0f7872329f3d8bb2c8e18d02e9df64ba7b465ab6" +source = "git+https://github.com/jlucaso1/whatsapp-rust.git?branch=main#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "buffa", "buffa-build", diff --git a/packages/whatsapp-rust-bridge/src/derivation_cache.rs b/packages/whatsapp-rust-bridge/src/derivation_cache.rs new file mode 100644 index 00000000000..a8807bf82cd --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/derivation_cache.rs @@ -0,0 +1,183 @@ +//! Keeps the XEdDSA derivations that a sender-key record cannot keep for us. +//! +//! `SenderKeyState` memoizes the signing key's basepoint multiplication and the +//! verifier's Edwards entries, but the memo lives in the record. This crate +//! rebuilds the record from storage on every operation, so the memo is always +//! cold and every message repays a derivation that never changes. +//! +//! Keeping the record alive instead would be the obvious fix and is the wrong +//! one: the record would go stale the moment anything else rotated the key, and +//! an encrypt under a chain the peer already dropped is worse than a slow one. +//! Keying on the signing key's public bytes has no such window. A rotated key +//! is a different entry by construction, and a stale entry is unreachable +//! rather than wrong. +//! +//! The core takes the derivations back through `prewarm_*`, which verify that +//! what they are handed belongs to the state before installing it, so a wrong +//! entry here cannot become a signature under the wrong key. + +use std::cell::RefCell; +use std::collections::HashMap; + +use wacore_libsignal::core::curve::PreparedVerifyingKey; +use wacore_libsignal::protocol::{PrivateKey, SenderKeyRecord}; + +/// Entries are ~200 bytes and a live conversation touches a handful of senders, +/// so this is far above any working set while staying bounded. On overflow the +/// map is cleared rather than evicted one by one: picking a victim needs +/// bookkeeping this does not otherwise carry, and the cost of being wrong is +/// one derivation per active sender, paid once. +const MAX_ENTRIES: usize = 256; + +#[derive(Clone)] +struct Derivations { + verifier: PreparedVerifyingKey, + /// Absent for a sender key received from someone else, which has no private + /// half to sign with. + signing: Option, +} + +thread_local! { + static CACHE: RefCell> = RefCell::new(HashMap::new()); +} + +/// Hand the record whatever has already been derived for its signing key, and +/// remember what it had to derive itself. +/// +/// Failures are silent by design: this is a cache, and a record it cannot read +/// is one the caller is about to fail on for a better reason. +pub(crate) fn warm(record: &SenderKeyRecord) { + let Ok(state) = record.sender_key_state() else { + return; + }; + let Ok(public) = state.signing_key_public() else { + return; + }; + let key = public.serialize(); + + let hit = CACHE.with(|cache| cache.borrow().get(&key).cloned()); + if let Some(derivations) = hit { + let _ = state.prewarm_verifying_key(derivations.verifier); + if let Some(signing) = derivations.signing { + let _ = state.prewarm_signing_key(signing); + } + + return; + } + + // A miss derives both halves even though one operation uses one of them. + // That is one wasted derivation per sender, against one per message. + let Ok(verifier) = state.signing_key_verifier() else { + return; + }; + let derivations = Derivations { + verifier: verifier.clone(), + signing: state.signing_key_private().ok(), + }; + + CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.len() >= MAX_ENTRIES { + cache.clear(); + } + + cache.insert(key, derivations); + }); +} + +#[cfg(test)] +pub(crate) fn reset() { + CACHE.with(|cache| cache.borrow_mut().clear()); +} + +#[cfg(test)] +pub(crate) fn len() -> usize { + CACHE.with(|cache| cache.borrow().len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use wacore_libsignal::protocol::KeyPair; + use wasm_bindgen_test::wasm_bindgen_test; + + fn keys() -> KeyPair { + KeyPair::generate(&mut rand::make_rng::()) + } + + /// Round-tripped through its serialized form, which is what leaves the memo + /// cold: a record built in place would already carry a warm one. + fn record_with(pair: &KeyPair, with_private: bool) -> SenderKeyRecord { + let mut record = SenderKeyRecord::new_empty(); + record + .add_sender_key_state( + 3, + 7, + 0, + &[9u8; 32], + pair.public_key, + with_private.then(|| pair.private_key.clone()), + ) + .expect("valid state"); + + SenderKeyRecord::deserialize(&record.serialize().expect("serialize")).expect("round trip") + } + + #[wasm_bindgen_test] + fn a_second_record_for_the_same_key_is_served_from_the_cache() { + reset(); + let pair = keys(); + + warm(&record_with(&pair, true)); + assert_eq!(len(), 1); + + // A record rebuilt from storage: same signing key, cold memo. + let rebuilt = record_with(&pair, true); + warm(&rebuilt); + + assert_eq!(len(), 1, "the same key must not add a second entry"); + let state = rebuilt.sender_key_state().expect("state"); + assert!( + state.signing_key_private().is_ok(), + "the served derivation has to be usable" + ); + } + + #[wasm_bindgen_test] + fn a_rotated_key_lands_on_its_own_entry() { + reset(); + warm(&record_with(&keys(), true)); + warm(&record_with(&keys(), true)); + + // Distinct public bytes, so a rotation can never be served a stale + // derivation: it simply misses. + assert_eq!(len(), 2); + } + + #[wasm_bindgen_test] + fn a_received_sender_key_caches_only_its_verifier() { + reset(); + let pair = keys(); + + warm(&record_with(&pair, false)); + + assert_eq!(len(), 1); + let rebuilt = record_with(&pair, false); + warm(&rebuilt); + let state = rebuilt.sender_key_state().expect("state"); + assert!( + state.signing_key_verifier().is_ok(), + "the verifier is the half a receiver needs" + ); + } + + #[wasm_bindgen_test] + fn the_map_stays_bounded() { + reset(); + for _ in 0..(MAX_ENTRIES + 2) { + warm(&record_with(&keys(), true)); + } + + assert!(len() <= MAX_ENTRIES, "got {}", len()); + } +} diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index 67002207b83..73da54bdeb3 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -5,6 +5,7 @@ pub mod binary; mod counter_lease; pub mod crypto; pub mod curve; +mod derivation_cache; pub mod group_cipher; pub mod group_types; #[cfg(feature = "image")] diff --git a/packages/whatsapp-rust-bridge/src/snapshot_store.rs b/packages/whatsapp-rust-bridge/src/snapshot_store.rs index e867923f2c5..ee2f54b6236 100644 --- a/packages/whatsapp-rust-bridge/src/snapshot_store.rs +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -89,7 +89,10 @@ impl SnapshotStore { } pub fn with_sender_key(&self, record: SenderKeyRecord) -> SignalResult<()> { - self.inner.borrow_mut().sender_key = Some(crate::counter_lease::waive_sender_key(record)?); + let record = crate::counter_lease::waive_sender_key(record)?; + // Mirror of the note in the adapter's `load_sender_key`. + crate::derivation_cache::warm(&record); + self.inner.borrow_mut().sender_key = Some(record); Ok(()) } diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index ef283227c9b..08858fbde21 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1161,6 +1161,8 @@ impl SenderKeyStore for JsStorageAdapter { }; let record = crate::counter_lease::waive_sender_key(record)?; + // The record is rebuilt per operation, so its derivation memo is cold. + crate::derivation_cache::warm(&record); self.cached_sender_keys .borrow_mut() From 1d9eb8c228dc56a6de8cf6f48708c3630c039ce1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 22:47:22 -0300 Subject: [PATCH 61/71] refactor(bridge): drop the legacy session JSON fallback The adapter would migrate a pre-WASM session object field by field when the store handed it one instead of bytes. Baileys never hands it one: `loadSession` converts a legacy record through the core's typed model first, or reports no session, so the branch was unreachable from the only consumer. It was also wrong. The fallback stored a message key's seed as its cipher key and zeroed the mac and iv, so the first ciphertext the old build enciphered failed its MAC. That is why the conversion moved to the typed model in the first place, and the comment at the call site has said so since. Its test asserted that the session opened and that encrypt returned a WhisperMessage, never that the peer could read it, so it would not have caught any of that. Removing both leaves one way to read a legacy record, the one that works. --- .../src/storage_adapter.rs | 265 +----------------- .../test/migration.test.ts | 155 ---------- 2 files changed, 7 insertions(+), 413 deletions(-) delete mode 100644 packages/whatsapp-rust-bridge/test/migration.test.ts diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 08858fbde21..de75e7b5e80 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -9,12 +9,8 @@ use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; use waproto::whatsapp::{ - RecordStructure, SenderKeyRecordStructure, SenderKeyStateStructure, SessionStructure, + SenderKeyRecordStructure, SenderKeyStateStructure, sender_key_state_structure::{SenderChainKey, SenderMessageKey, SenderSigningKey}, - session_structure::{ - Chain, - chain::{ChainKey, MessageKey}, - }, }; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; @@ -241,229 +237,6 @@ impl JsStorageAdapter { key_id } - async fn migrate_legacy_json(&self, value: JsValue) -> SignalResult>> { - let has_reg_id = - js_sys::Reflect::has(&value, &JsValue::from_str("registrationId")).unwrap_or(false); - let has_ratchet = - js_sys::Reflect::has(&value, &JsValue::from_str("currentRatchet")).unwrap_or(false); - - let session_data = if has_reg_id && has_ratchet { - value - } else { - let has_sessions = - js_sys::Reflect::has(&value, &JsValue::from_str("_sessions")).unwrap_or(false); - if !has_sessions { - return Ok(None); - } - - let sessions = get_object(&value, "_sessions") - .ok_or_else(|| invalid_js_data("migrate", "Missing _sessions"))?; - let sessions_obj = sessions - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "Invalid _sessions object"))?; - let keys = js_sys::Object::keys(sessions_obj); - - if keys.length() == 0 { - return Ok(None); - } - - let key = keys.get(0); - js_sys::Reflect::get(&sessions, &key).map_err(js_to_signal_error)? - }; - - let has_reg_id_inner = - js_sys::Reflect::has(&session_data, &JsValue::from_str("registrationId")) - .unwrap_or(false); - if !has_reg_id_inner { - return Ok(None); - } - - let local_identity = self.get_identity_key_pair().await?; - let local_identity_public = local_identity.public_key().serialize().into(); - - let registration_id = get_number(&session_data, "registrationId").unwrap_or(0.0) as u32; - - let current_ratchet = get_object(&session_data, "currentRatchet") - .ok_or_else(|| invalid_js_data("migrate", "Missing currentRatchet"))?; - let root_key_b64 = get_string(¤t_ratchet, "rootKey").unwrap_or_default(); - let root_key = BASE64_STANDARD.decode(root_key_b64).unwrap_or_default(); - - let previous_counter = - get_number(¤t_ratchet, "previousCounter").unwrap_or(0.0) as u32; - - let ephemeral_key_pair = get_object(¤t_ratchet, "ephemeralKeyPair") - .ok_or_else(|| invalid_js_data("migrate", "Missing ephemeralKeyPair"))?; - let sender_ratchet_pub_b64 = get_string(&ephemeral_key_pair, "pubKey").unwrap_or_default(); - let sender_ratchet_priv_b64 = - get_string(&ephemeral_key_pair, "privKey").unwrap_or_default(); - - let sender_ratchet_pub = BASE64_STANDARD - .decode(sender_ratchet_pub_b64) - .unwrap_or_default(); - let sender_ratchet_priv = BASE64_STANDARD - .decode(sender_ratchet_priv_b64) - .unwrap_or_default(); - - let index_info = get_object(&session_data, "indexInfo") - .ok_or_else(|| invalid_js_data("migrate", "Missing indexInfo"))?; - let remote_identity_b64 = get_string(&index_info, "remoteIdentityKey").unwrap_or_default(); - let remote_identity = BASE64_STANDARD - .decode(remote_identity_b64) - .unwrap_or_default(); - - let base_key_b64 = get_string(&index_info, "baseKey").unwrap_or_default(); - let base_key = BASE64_STANDARD.decode(base_key_b64).unwrap_or_default(); - - let chains = get_object(&session_data, "_chains") - .ok_or_else(|| invalid_js_data("migrate", "Missing _chains"))?; - let chains_obj = chains - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "_chains expected to be an object"))?; - let chain_keys = js_sys::Object::keys(chains_obj); - - let mut sender_chain = None; - let mut receiver_chains = Vec::new(); - - for i in 0..chain_keys.length() { - let key = chain_keys.get(i); - let chain = js_sys::Reflect::get(&chains, &key).map_err(|err| { - invalid_js_data( - "migrate", - format!( - "Failed to read chain entry {:?}: {:?}", - key.as_string(), - err - ), - ) - })?; - let chain_type = get_number(&chain, "chainType").unwrap_or(0.0) as u32; - - let chain_key_obj = get_object(&chain, "chainKey").ok_or_else(|| { - invalid_js_data("migrate", "Missing chainKey for legacy chain entry") - })?; - let counter = get_number(&chain_key_obj, "counter").unwrap_or(0.0) as u32; - let key_b64 = get_string(&chain_key_obj, "key").unwrap_or_default(); - let key_bytes = BASE64_STANDARD.decode(key_b64).unwrap_or_default(); - - let message_keys_obj = get_object(&chain, "messageKeys").ok_or_else(|| { - invalid_js_data("migrate", "Missing messageKeys for legacy chain entry") - })?; - let message_keys_object = message_keys_obj - .dyn_ref::() - .ok_or_else(|| invalid_js_data("migrate", "Invalid messageKeys object"))?; - let msg_keys_list = js_sys::Object::keys(message_keys_object); - let mut message_keys = Vec::new(); - - for j in 0..msg_keys_list.length() { - let idx_val = msg_keys_list.get(j); - let idx = idx_val.as_f64().ok_or_else(|| { - invalid_js_data("migrate", "Message key index is not a number") - })? as u32; - let msg_key_b64 = js_sys::Reflect::get(&message_keys_obj, &idx_val) - .map_err(|err| { - invalid_js_data( - "migrate", - format!("Missing message key {}: {:?}", idx, err), - ) - })? - .as_string() - .unwrap_or_default(); - let msg_key_bytes = BASE64_STANDARD.decode(msg_key_b64).unwrap_or_default(); - message_keys.push((idx, msg_key_bytes)); - } - - if chain_type == 1 { - sender_chain = Some(( - sender_ratchet_pub.clone(), - sender_ratchet_priv.clone(), - key_bytes, - counter, - message_keys, - )); - } else if chain_type == 2 { - let sender_ratchet_key_b64 = key.as_string().unwrap_or_default(); - let sender_ratchet_key = BASE64_STANDARD - .decode(sender_ratchet_key_b64) - .unwrap_or_default(); - receiver_chains.push((sender_ratchet_key, key_bytes, counter, message_keys)); - } - } - - let mut sender_chain_struct = MessageField::none(); - - if let Some((pub_key, priv_key, chain_key, counter, msg_keys)) = sender_chain { - let mut message_keys_vec = Vec::new(); - for (idx, key) in msg_keys { - message_keys_vec.push(MessageKey { - index: Some(idx), - cipher_key: Some(key.into()), - mac_key: Some(vec![0u8; 32].into()), - iv: Some(vec![0u8; 16].into()), - seed: None, - }); - } - - sender_chain_struct = MessageField::some(Chain { - sender_ratchet_key: Some(pub_key), - sender_ratchet_key_private: Some(priv_key), - chain_key: MessageField::some(ChainKey { - index: Some(counter), - key: Some(chain_key.into()), - }), - message_keys: message_keys_vec, - }); - } - - let mut receiver_chains_vec = Vec::new(); - for (sender_ratchet, chain_key, counter, msg_keys) in receiver_chains { - let mut message_keys_vec = Vec::new(); - for (idx, key) in msg_keys { - message_keys_vec.push(MessageKey { - index: Some(idx), - cipher_key: Some(key.into()), - mac_key: Some(vec![0u8; 32].into()), - iv: Some(vec![0u8; 16].into()), - seed: None, - }); - } - - receiver_chains_vec.push(Chain { - sender_ratchet_key: Some(sender_ratchet), - sender_ratchet_key_private: None, - chain_key: MessageField::some(ChainKey { - index: Some(counter), - key: Some(chain_key.into()), - }), - message_keys: message_keys_vec, - }); - } - - let local_reg_id = self.get_local_registration_id().await?; - - let session = SessionStructure { - session_version: Some(3), - local_identity_public: Some(local_identity_public), - remote_identity_public: Some(remote_identity), - root_key: Some(root_key), - previous_counter: Some(previous_counter), - sender_chain: sender_chain_struct, - receiver_chains: receiver_chains_vec, - pending_key_exchange: MessageField::none(), - pending_pre_key: MessageField::none(), - remote_registration_id: Some(registration_id), - local_registration_id: Some(local_reg_id), - needs_refresh: None, - alice_base_key: Some(base_key), - }; - - let record = RecordStructure { - current_session: MessageField::some(session), - previous_sessions: Vec::new(), - }; - - Ok(Some(record.encode_to_vec())) - } - fn migrate_legacy_sender_key(&self, data: &[u8]) -> SignalResult>> { let json_str = match std::str::from_utf8(data) { Ok(s) => s, @@ -741,27 +514,6 @@ fn js_value_to_bytes(value: &JsValue) -> SignalResult>> { Ok(None) } -fn is_legacy_session_object(value: &JsValue) -> bool { - let has_sessions = - js_sys::Reflect::has(value, &JsValue::from_str("_sessions")).unwrap_or(false); - let has_reg_id = - js_sys::Reflect::has(value, &JsValue::from_str("registrationId")).unwrap_or(false); - let has_ratchet = - js_sys::Reflect::has(value, &JsValue::from_str("currentRatchet")).unwrap_or(false); - - has_sessions || (has_reg_id && has_ratchet) -} - -fn get_string(obj: &JsValue, key: &str) -> Option { - js_sys::Reflect::get(obj, &JsValue::from_str(key)) - .ok() - .and_then(|v| v.as_string()) -} - -/// Reflect::get answers `Ok(undefined)` for a property that is not there, so -/// mapping it straight to Some() makes "absent" indistinguishable from "present". -/// Callers rely on None to default or to raise a clear error, and one of them fed -/// the undefined to `Array::from`, which throws across the boundary. fn get_object(obj: &JsValue, key: &str) -> Option { let value = js_sys::Reflect::get(obj, &JsValue::from_str(key)).ok()?; if value.is_undefined() || value.is_null() { @@ -847,15 +599,12 @@ impl SessionStore for JsStorageAdapter { return Ok(None); } - let bytes = if let Some(b) = js_value_to_bytes(&value)? { - Some(b) - } else if is_legacy_session_object(&value) { - self.migrate_legacy_json(value).await? - } else { - None - }; - - match bytes { + // Only bytes. A caller holding a pre-WASM JSON record converts it + // through the core's typed model before handing it over; the + // field-by-field fallback that used to live here could not, since it + // stored a message key's seed as its cipher key and zeroed the mac and + // iv, so the first ciphertext the old build enciphered failed its MAC. + match js_value_to_bytes(&value)? { Some(data) => { let record = crate::counter_lease::waive_session(CoreSessionRecord::deserialize(&data)?); diff --git a/packages/whatsapp-rust-bridge/test/migration.test.ts b/packages/whatsapp-rust-bridge/test/migration.test.ts deleted file mode 100644 index cbd31eb7e83..00000000000 --- a/packages/whatsapp-rust-bridge/test/migration.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect } from "@jest/globals"; -import { - ProtocolAddress, - SessionCipher, - generateIdentityKeyPair, - generatePreKey, -} from "../dist/index.js"; -import { FakeStorage } from "./helpers/fake_storage"; - -describe("Legacy Session Migration", () => { - it("should migrate a legacy JSON session into a valid open session", async () => { - // await init(); - const aliceAddress = new ProtocolAddress("alice", 1); - const storage = new FakeStorage(); - - // Generate valid keys for the legacy session - const identity = generateIdentityKeyPair(); - const ephemeral = generatePreKey(1); - const sendingEphemeral = generatePreKey(2); - const remoteIdentity = generateIdentityKeyPair(); - const rootKey = Buffer.alloc(32, 1); - const chainKey = Buffer.alloc(32, 2); - const baseKey = generateIdentityKeyPair().pubKey; // Use valid public key (33 bytes) - - // Helper to toBase64 - const toB64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); - - // 1. Construct a Legacy libsignal-node JSON Session - const legacySession = { - registrationId: 12345, - currentRatchet: { - ephemeralKeyPair: { - pubKey: toB64(ephemeral.keyPair.pubKey), - privKey: toB64(ephemeral.keyPair.privKey), - }, - lastRemoteEphemeralKey: toB64(ephemeral.keyPair.pubKey), // Reuse for simplicity - previousCounter: 0, - rootKey: toB64(rootKey), - }, - indexInfo: { - baseKey: toB64(baseKey), - baseKeyType: 2, - closed: -1, - used: Date.now(), - created: Date.now(), - remoteIdentityKey: toB64(remoteIdentity.pubKey), - }, - _chains: { - // A receiving chain - [toB64(ephemeral.keyPair.pubKey)]: { - chainKey: { - counter: 0, - key: toB64(chainKey), - }, - chainType: 2, // RECEIVING - messageKeys: {}, - }, - // A sending chain - [toB64(sendingEphemeral.keyPair.pubKey)]: { - chainKey: { - counter: 0, - key: toB64(chainKey), - }, - chainType: 1, // SENDING - messageKeys: {}, - }, - }, - version: "v1", - }; - - // Mock storage to return this legacy structure - // @ts-ignore - storage.loadSession = async () => legacySession; - - // 2. Initialize Cipher (which calls storage.loadSession) - const cipher = new SessionCipher(storage, aliceAddress); - - // 3. Verify the session is recognized as OPEN - // If migration SUCCEEDED, this should be TRUE. - const isOpen = await cipher.hasOpenSession(); - expect(isOpen).toBe(true); - - // 4. Verify encryption works (requires an open session) - // We need to trust the identity first to allow encryption - await storage.trustIdentity("alice", remoteIdentity.pubKey); - - const plaintext = new Uint8Array([1, 2, 3, 4]); - - // Attempting to encrypt should now SUCCEED because we have a session - const result = await cipher.encrypt(plaintext); - expect(result.type).toBe(2); // WhisperMessage - expect(result.body).toBeInstanceOf(Uint8Array); - }); - - it("should migrate a legacy JSON session wrapped in _sessions", async () => { - const aliceAddress = new ProtocolAddress("alice_wrapped", 1); - const storage = new FakeStorage(); - - // Generate valid keys for the legacy session - const identity = generateIdentityKeyPair(); - const ephemeral = generatePreKey(1); - const remoteIdentity = generateIdentityKeyPair(); - const rootKey = Buffer.alloc(32, 1); - const chainKey = Buffer.alloc(32, 2); - const baseKey = generateIdentityKeyPair().pubKey; - - const toB64 = (b: Uint8Array) => Buffer.from(b).toString("base64"); - - const legacySession = { - registrationId: 12345, - currentRatchet: { - ephemeralKeyPair: { - pubKey: toB64(ephemeral.keyPair.pubKey), - privKey: toB64(ephemeral.keyPair.privKey), - }, - lastRemoteEphemeralKey: toB64(ephemeral.keyPair.pubKey), - previousCounter: 0, - rootKey: toB64(rootKey), - }, - indexInfo: { - baseKey: toB64(baseKey), - baseKeyType: 2, - closed: -1, - used: Date.now(), - created: Date.now(), - remoteIdentityKey: toB64(remoteIdentity.pubKey), - }, - _chains: { - [toB64(ephemeral.keyPair.pubKey)]: { - chainKey: { - counter: 0, - key: toB64(chainKey), - }, - chainType: 2, - messageKeys: {}, - }, - }, - version: "v1", - }; - - const wrappedSession = { - _sessions: { - "some-random-key": legacySession, - }, - }; - - // @ts-ignore - storage.loadSession = async () => wrappedSession; - - const cipher = new SessionCipher(storage, aliceAddress); - - const isOpen = await cipher.hasOpenSession(); - expect(isOpen).toBe(true); - }); -}); From ddb7f26969b452866a5d2d8e03906ed5a17fe4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 23:08:03 -0300 Subject: [PATCH 62/71] refactor(bridge): drop the callback-based session API The snapshot calls replaced SessionCipher and SessionBuilder for the whole direct-message path, and Baileys is the only consumer, so nothing reached them any more. Removing the pair took the adapter's session, identity, pre-key and signed-pre-key stores with it: those existed to serve those two types. The adapter now backs the group path alone, which needs sender keys and nothing else. The cascade the compiler then found came out too: five payload structs, the address and identity caches, the raw-store probe, and the extern declarations for eleven JS callbacks the bridge no longer calls. The SignalStorage interface goes from twelve members to two. wasm-opt output drops 7.6%, from 1,038,376 to 959,313 bytes for the SIMD build and 1,095,158 to 1,012,934 for the other. Three test files went with the API. Their scenarios did not: the bundle round trip now runs through processBundleWithSnapshot, and simultaneous initiation, injection followed by an incoming prekey message, and the guarantee that reprocessing a bundle does not discard a live session are expressed against the snapshot calls in snapshot_api.test.ts. What is left of storage_adapter.test.ts is the one case that still applies: a rejected write must not be cached as if it had succeeded. --- packages/whatsapp-rust-bridge/src/lib.rs | 2 - .../src/session_builder.rs | 131 ---- .../src/session_cipher.rs | 159 ----- .../src/storage_adapter.rs | 595 +----------------- .../test/session_builder.test.ts | 141 ----- .../test/session_cipher.test.ts | 155 ----- .../test/simultaneous_session.test.ts | 332 ---------- .../test/snapshot_api.test.ts | 120 +++- .../test/storage_adapter.test.ts | 197 +----- 9 files changed, 117 insertions(+), 1715 deletions(-) delete mode 100644 packages/whatsapp-rust-bridge/src/session_builder.rs delete mode 100644 packages/whatsapp-rust-bridge/src/session_cipher.rs delete mode 100644 packages/whatsapp-rust-bridge/test/session_builder.test.ts delete mode 100644 packages/whatsapp-rust-bridge/test/session_cipher.test.ts delete mode 100644 packages/whatsapp-rust-bridge/test/simultaneous_session.test.ts diff --git a/packages/whatsapp-rust-bridge/src/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index 73da54bdeb3..81108b28a21 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -16,8 +16,6 @@ pub mod logger; pub mod noise_session; pub mod protocol_address; pub mod sender_key_name; -pub mod session_builder; -pub mod session_cipher; pub mod session_record; pub mod snapshot_api; pub mod snapshot_store; diff --git a/packages/whatsapp-rust-bridge/src/session_builder.rs b/packages/whatsapp-rust-bridge/src/session_builder.rs deleted file mode 100644 index 107569cfd3b..00000000000 --- a/packages/whatsapp-rust-bridge/src/session_builder.rs +++ /dev/null @@ -1,131 +0,0 @@ -use rand::rngs::StdRng; -use serde::Deserialize; -use tsify::Tsify; -use wasm_bindgen::{JsValue, prelude::*}; - -use crate::protocol_address::ProtocolAddress; -use crate::session_record::SessionRecord; -use crate::storage_adapter::{JsStorageAdapter, SignalStorage}; -use wacore_libsignal::core::curve::PublicKey as CorePublicKey; -use wacore_libsignal::protocol::{ - self as libsignal, PreKeyBundle, SessionRecord as CoreSessionRecord, SignalProtocolError, - UsePQRatchet, -}; - -fn map_err(e: impl std::fmt::Display) -> JsValue { - JsValue::from_str(&e.to_string()) -} - -impl SessionRecord { - pub fn from_core(core_record: &CoreSessionRecord) -> Result { - Ok(Self::new(core_record.serialize()?)) - } - - pub fn to_core(&self) -> Result { - Ok(crate::counter_lease::waive_session( - CoreSessionRecord::deserialize(&self.serialized_data)?, - )) - } -} - -#[derive(Deserialize, Tsify)] -#[tsify(from_wasm_abi)] -#[serde(rename_all = "camelCase")] -pub struct PreKeyPublicKey { - pub key_id: u32, - #[tsify(type = "Uint8Array")] - pub public_key: Vec, -} - -#[derive(Deserialize, Tsify)] -#[tsify(from_wasm_abi)] -#[serde(rename_all = "camelCase")] -pub struct SignedPreKeyPublicKey { - pub key_id: u32, - #[tsify(type = "Uint8Array")] - pub public_key: Vec, - #[tsify(type = "Uint8Array")] - pub signature: Vec, -} - -#[derive(Deserialize, Tsify)] -#[tsify(from_wasm_abi)] -#[serde(rename_all = "camelCase")] -pub struct PreKeyBundleInput { - pub registration_id: u32, - #[tsify(type = "Uint8Array")] - pub identity_key: Vec, - #[serde(default)] - pub pre_key: Option, - pub signed_pre_key: SignedPreKeyPublicKey, -} - -#[wasm_bindgen(js_name = SessionBuilder)] -pub struct SessionBuilder { - storage_adapter: JsStorageAdapter, - remote_address: ProtocolAddress, -} - -#[wasm_bindgen(js_class = SessionBuilder)] -impl SessionBuilder { - #[wasm_bindgen(constructor)] - pub fn new(storage: SignalStorage, remote_address: &ProtocolAddress) -> Self { - Self { - storage_adapter: JsStorageAdapter::new(storage), - remote_address: ProtocolAddress(remote_address.0.clone()), - } - } - - #[wasm_bindgen(js_name = processPreKeyBundle)] - pub async fn process_prekey_bundle( - &mut self, - bundle_input: PreKeyBundleInput, - ) -> Result<(), JsValue> { - let pre_key = bundle_input - .pre_key - .map(|pk| { - CorePublicKey::deserialize(&pk.public_key) - .map(|key| (pk.key_id.into(), key)) - .map_err(map_err) - }) - .transpose()?; - - let signed_pre_key_public = - CorePublicKey::deserialize(&bundle_input.signed_pre_key.public_key).map_err(map_err)?; - - let identity_key = - libsignal::IdentityKey::decode(&bundle_input.identity_key).map_err(map_err)?; - - let bundle = PreKeyBundle::new( - bundle_input.registration_id, - self.remote_address.0.device_id(), - pre_key, - bundle_input.signed_pre_key.key_id.into(), - signed_pre_key_public, - bundle_input.signed_pre_key.signature, - identity_key, - ) - .map_err(map_err)?; - - let mut session_store = self.storage_adapter.clone(); - let mut identity_store = self.storage_adapter.clone(); - - libsignal::process_prekey_bundle( - &self.remote_address.0, - &mut session_store, - &mut identity_store, - &bundle, - &mut rand::make_rng::(), - UsePQRatchet::No, - ) - .await - .map_err(map_err)?; - - Ok(()) - } - - #[wasm_bindgen(js_name = initOutgoing)] - pub async fn init_outgoing(&mut self, bundle_input: PreKeyBundleInput) -> Result<(), JsValue> { - self.process_prekey_bundle(bundle_input).await - } -} diff --git a/packages/whatsapp-rust-bridge/src/session_cipher.rs b/packages/whatsapp-rust-bridge/src/session_cipher.rs deleted file mode 100644 index 00792888988..00000000000 --- a/packages/whatsapp-rust-bridge/src/session_cipher.rs +++ /dev/null @@ -1,159 +0,0 @@ -use js_sys::{Object, Reflect, Uint8Array}; -use rand::rngs::StdRng; -use std::cell::RefCell; -use wasm_bindgen::prelude::*; - -use crate::{ - protocol_address::ProtocolAddress, - storage_adapter::{JsStorageAdapter, SignalStorage}, -}; -use wacore_libsignal::protocol::{self as libsignal, PreKeyStore, SessionStore, UsePQRatchet}; - -#[inline] -fn bytes_to_uint8array(bytes: &[u8]) -> Uint8Array { - Uint8Array::from(bytes) -} - -#[wasm_bindgen] -extern "C" { - #[wasm_bindgen(extends = Object, typescript_type = "{ type: number; body: Uint8Array }")] - pub type EncryptResult; -} - -thread_local! { - static TYPE_KEY: RefCell = RefCell::new(JsValue::from_str("type")); - static BODY_KEY: RefCell = RefCell::new(JsValue::from_str("body")); -} - -#[wasm_bindgen(js_name = SessionCipher)] -pub struct SessionCipher { - storage_adapter: JsStorageAdapter, - remote_address: ProtocolAddress, -} - -#[wasm_bindgen(js_class = SessionCipher)] -impl SessionCipher { - #[wasm_bindgen(constructor)] - pub fn new(storage: SignalStorage, remote_address: &ProtocolAddress) -> Self { - Self { - storage_adapter: JsStorageAdapter::new(storage), - remote_address: ProtocolAddress(remote_address.0.clone()), - } - } - - pub async fn encrypt(&mut self, plaintext: &[u8]) -> Result { - let mut session_store = self.storage_adapter.clone(); - let mut identity_store = session_store.clone(); - - let ciphertext_message = libsignal::message_encrypt( - plaintext, - &self.remote_address.0, - &mut session_store, - &mut identity_store, - ) - .await - .map_err(|e| { - let msg = format!("SessionCipher.encrypt error: {:?}", e); - JsValue::from_str(&msg) - })?; - - let body_array = bytes_to_uint8array(ciphertext_message.serialize()); - let type_id = ciphertext_message.message_type() as u8; - - let result = Object::new(); - TYPE_KEY.with(|k| Reflect::set(&result, &k.borrow(), &(type_id as u32).into()))?; - BODY_KEY.with(|k| Reflect::set(&result, &k.borrow(), &body_array.into()))?; - - Ok(result.unchecked_into()) - } - - #[wasm_bindgen(js_name = decryptPreKeyWhisperMessage)] - pub async fn decrypt_prekey_whisper_message( - &mut self, - ciphertext: &[u8], - ) -> Result { - let prekey_message = libsignal::PreKeySignalMessage::try_from(ciphertext) - .map_err(|e| { - let msg = format!("SessionCipher.decryptPreKeyWhisperMessage failed: Invalid PreKeyMessage format: {}", e); - JsValue::from_str(&msg) - })?; - - let mut session_store = self.storage_adapter.clone(); - let mut identity_store = session_store.clone(); - let mut prekey_store = session_store.clone(); - let signed_prekey_store = session_store.clone(); - - let plaintext = libsignal::message_decrypt_prekey( - &prekey_message, - &self.remote_address.0, - &mut session_store, - &mut identity_store, - &mut prekey_store, - &signed_prekey_store, - &mut rand::make_rng::(), - UsePQRatchet::No, - ) - .await - .map_err(|e| { - let msg = format!("SessionCipher.decryptPreKeyWhisperMessage failed: {:?}", e); - JsValue::from_str(&msg) - })?; - - // The core reports the one-time key it consumed rather than deleting it, - // so this path has to do the delete itself, or a spent pre-key stays in - // storage and can be handed out again. The message is already decrypted - // at this point, so a storage failure here is logged rather than raised: - // dropping the plaintext would lose a message to a cleanup problem. - if let Some(id) = plaintext.consumed_prekey_id - && let Err(e) = PreKeyStore::remove_pre_key(&mut prekey_store, id).await - { - log::warn!("failed to remove consumed pre-key {:?}: {:?}", id, e); - } - - Ok(bytes_to_uint8array(&plaintext.plaintext)) - } - - #[wasm_bindgen(js_name = decryptWhisperMessage)] - pub async fn decrypt_whisper_message( - &mut self, - ciphertext: &[u8], - ) -> Result { - let signal_message = libsignal::SignalMessage::try_from(ciphertext).map_err(|e| { - let msg = format!( - "SessionCipher.decryptWhisperMessage failed: Invalid WhisperMessage format: {}", - e - ); - JsValue::from_str(&msg) - })?; - - let mut session_store = self.storage_adapter.clone(); - let mut identity_store = session_store.clone(); - - let plaintext = libsignal::message_decrypt_signal( - &signal_message, - &self.remote_address.0, - &mut session_store, - &mut identity_store, - &mut rand::make_rng::(), - ) - .await - .map_err(|e| { - let msg = format!("SessionCipher.decryptWhisperMessage failed: {:?}", e); - JsValue::from_str(&msg) - })?; - - Ok(bytes_to_uint8array(&plaintext.plaintext)) - } - - #[wasm_bindgen(js_name = hasOpenSession)] - pub async fn has_open_session(&self) -> Result { - let record = SessionStore::load_session(&self.storage_adapter, &self.remote_address.0) - .await - .map_err(|e| JsValue::from_str(&e.to_string()))?; - - match record { - Some(r) => Ok(r.session_state().is_some()), - None => Ok(false), - } - } -} diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index de75e7b5e80..5b6616518a3 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -2,9 +2,6 @@ use async_trait::async_trait; use base64::prelude::*; use buffa::{Message as _, MessageField}; use js_sys::{Promise, Uint8Array}; -use serde::Deserialize; -use serde::de::DeserializeOwned; -use serde_bytes::ByteBuf; use std::cell::RefCell; use std::collections::HashMap; use std::rc::Rc; @@ -15,35 +12,18 @@ use waproto::whatsapp::{ use wasm_bindgen::prelude::*; use wasm_bindgen_futures::JsFuture; -use wacore_libsignal::protocol::{ - self as libsignal, DeviceId, Direction as StoreDirection, GenericSignedPreKey as _, - IdentityChange, IdentityKey, IdentityKeyPair, IdentityKeyStore, KeyPair, PreKeyId, - PreKeyRecord, PreKeyStore, PrivateKey, SenderKeyStore, SessionStore, SignedPreKeyId, - SignedPreKeyRecord, SignedPreKeyStore, -}; +use wacore_libsignal::protocol::{self as libsignal, SenderKeyStore}; type SignalResult = wacore_libsignal::protocol::error::Result; use wacore_libsignal::protocol::SenderKeyRecord as CoreSenderKeyRecord; -use wacore_libsignal::protocol::SessionRecord as CoreSessionRecord; use wacore_libsignal::protocol::SignalProtocolError; -use wacore_libsignal::protocol::Timestamp; use wacore_libsignal::store::sender_key_name::SenderKeyName as CoreSenderKeyName; -use crate::session_record::{SessionRecord, js_array_to_vec}; +use crate::session_record::js_array_to_vec; #[wasm_bindgen(typescript_custom_section)] const TS_SIGNAL_STORAGE: &str = r#" export interface SignalStorage { - loadSession(address: string): Uint8Array | null | undefined | Promise; - storeSession(address: string, record: SessionRecord): void | Promise; - getOurIdentity(): KeyPair | Promise; - getOurRegistrationId(): number | Promise; - isTrustedIdentity(name: string, identityKey: Uint8Array, direction: number): boolean | Promise; - loadIdentityKey?(name: string): Uint8Array | null | undefined | Promise; - saveIdentity?(name: string, identityKey: Uint8Array): boolean | Promise; - loadPreKey(id: number): KeyPair | null | undefined | Promise; - removePreKey(id: number): void | Promise; - loadSignedPreKey(id: number): SignedPreKey | null | undefined | Promise; loadSenderKey(keyId: string): Uint8Array | null | undefined | Promise; storeSenderKey(keyId: string, record: Uint8Array): void | Promise; } @@ -55,56 +35,6 @@ extern "C" { #[derive(Clone)] pub type SignalStorage; - #[wasm_bindgen(structural, method, catch, js_name = loadSession)] - fn js_load_session(this: &SignalStorage, address: &str) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = storeSession)] - fn js_store_session( - this: &SignalStorage, - address: &str, - record: JsValue, - ) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = storeSessionRaw)] - fn js_store_session_raw( - this: &SignalStorage, - address: &str, - data: &Uint8Array, - ) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = getOurIdentity)] - fn js_get_our_identity(this: &SignalStorage) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = getOurRegistrationId)] - fn js_get_our_registration_id(this: &SignalStorage) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = isTrustedIdentity)] - fn js_is_trusted_identity( - this: &SignalStorage, - name: &str, - identity_key: &Uint8Array, - direction: u32, - ) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = loadIdentityKey)] - fn js_load_identity_key(this: &SignalStorage, name: &str) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = saveIdentity)] - fn js_save_identity( - this: &SignalStorage, - name: &str, - identity_key: &Uint8Array, - ) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = loadPreKey)] - fn js_load_pre_key(this: &SignalStorage, id: u32) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = removePreKey)] - fn js_remove_pre_key(this: &SignalStorage, id: u32) -> Result; - - #[wasm_bindgen(structural, method, catch, js_name = loadSignedPreKey)] - fn js_load_signed_pre_key(this: &SignalStorage, id: u32) -> Result; - #[wasm_bindgen(structural, method, catch, js_name = loadSenderKey)] fn js_load_sender_key(this: &SignalStorage, key_id: &str) -> Result; @@ -119,13 +49,7 @@ extern "C" { #[derive(Clone)] pub struct JsStorageAdapter { pub js_storage: SignalStorage, - cached_identity_key_pair: Rc>>, - cached_registration_id: Rc>>, - cached_sessions: Rc>>, cached_sender_keys: Rc>>, - cached_identities: Rc>>>, - has_store_session_raw: Rc>>, - last_address_cache: Rc>>, last_sender_key_cache: Rc>>, } @@ -133,87 +57,11 @@ impl JsStorageAdapter { pub fn new(js_storage: SignalStorage) -> Self { Self { js_storage, - cached_identity_key_pair: Rc::new(RefCell::new(None)), - cached_registration_id: Rc::new(RefCell::new(None)), - cached_sessions: Rc::new(RefCell::new(HashMap::new())), cached_sender_keys: Rc::new(RefCell::new(HashMap::new())), - cached_identities: Rc::new(RefCell::new(HashMap::new())), - has_store_session_raw: Rc::new(RefCell::new(None)), - last_address_cache: Rc::new(RefCell::new(None)), last_sender_key_cache: Rc::new(RefCell::new(None)), } } - fn has_store_session_raw(&self) -> bool { - if let Some(has_raw) = *self.has_store_session_raw.borrow() { - return has_raw; - } - - let has_raw = js_sys::Reflect::has(&self.js_storage, &JsValue::from_str("storeSessionRaw")) - .unwrap_or(false); - self.has_store_session_raw.borrow_mut().replace(has_raw); - has_raw - } - - fn has_js_method(&self, name: &str) -> bool { - js_sys::Reflect::get(&self.js_storage, &JsValue::from_str(name)) - .map(|value| value.is_function()) - .unwrap_or(false) - } - - async fn load_peer_identity(&self, address: &str) -> SignalResult>> { - if let Some(identity) = self.cached_identities.borrow().get(address) { - return Ok(Some(identity.clone())); - } - - if !self.has_js_method("loadIdentityKey") { - return Ok(None); - } - - let result = self - .js_storage - .js_load_identity_key(address) - .map_err(js_to_signal_error)?; - let Some(value) = resolve_maybe_promise_optional(result).await? else { - return Ok(None); - }; - let identity = js_value_to_bytes(&value)?.ok_or_else(|| { - invalid_js_data( - "load_identity_key", - "Expected Uint8Array, Array, or Buffer-like object", - ) - })?; - - self.cached_identities - .borrow_mut() - .insert(address.to_string(), identity.clone()); - - Ok(Some(identity)) - } - - #[inline] - fn get_address_string(&self, address: &libsignal::ProtocolAddress) -> String { - // Two devices of one user share a name, so the device id has to be part - // of the key: matching on the name alone would hand back another - // device's address. - let name = address.name(); - let device = address.device_id(); - let cache = self.last_address_cache.borrow(); - if let Some((cached_name, cached_device, cached_str)) = cache.as_ref() - && cached_name == name - && *cached_device == device - { - return cached_str.clone(); - } - drop(cache); - - let addr_str = address.to_string(); - self.last_address_cache - .borrow_mut() - .replace((name.to_string(), device, addr_str.clone())); - addr_str - } - #[inline] fn get_sender_key_id(&self, sender_key_name: &CoreSenderKeyName) -> String { let group_id = sender_key_name.group_id(); @@ -323,140 +171,10 @@ impl JsStorageAdapter { } } -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct JsKeyPairBytes { - #[serde(default, alias = "pubKey", alias = "publicKey", alias = "public")] - public_key: Option, - #[serde(default, alias = "privKey", alias = "privateKey", alias = "private")] - private_key: Option, -} - -impl JsKeyPairBytes { - fn into_vecs(self) -> Option<(Vec, Vec)> { - match (self.public_key, self.private_key) { - (Some(public_key), Some(private_key)) => { - Some((public_key.into_vec(), private_key.into_vec())) - } - _ => None, - } - } -} - -#[derive(Debug, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct JsKeyEnvelope { - #[serde(flatten)] - inline: JsKeyPairBytes, - #[serde(default, rename = "keyPair", alias = "key_pair")] - key_pair: Option, -} - -impl JsKeyEnvelope { - fn into_vecs(self) -> Option<(Vec, Vec)> { - self.inline - .into_vecs() - .or_else(|| self.key_pair.and_then(|pair| pair.into_vecs())) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct JsPreKeyRecordPayload { - #[serde(default, alias = "preKeyId", alias = "keyId")] - id: Option, - #[serde(flatten)] - keys: JsKeyEnvelope, -} - -impl JsPreKeyRecordPayload { - fn into_record(self, requested_id: PreKeyId) -> SignalResult { - let effective_id = self.id.unwrap_or_else(|| requested_id.into()); - let (public_key, private_key) = self - .keys - .into_vecs() - .ok_or_else(|| invalid_js_data("load_pre_key", "Missing public/private key bytes"))?; - - let normalized_public_key = ensure_curve_key_with_prefix(public_key); - let key_pair = KeyPair::from_public_and_private(&normalized_public_key, &private_key)?; - Ok(PreKeyRecord::new(PreKeyId::from(effective_id), &key_pair)) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct JsSignedPreKeyRecordPayload { - #[serde(default, alias = "keyId")] - id: Option, - #[serde(default)] - timestamp: Option, - #[serde(default, alias = "sig", alias = "signatureBytes")] - signature: Option, - #[serde(flatten)] - keys: JsKeyEnvelope, -} - -impl JsSignedPreKeyRecordPayload { - fn into_record(self, requested_id: SignedPreKeyId) -> SignalResult { - let effective_id = self.id.unwrap_or_else(|| requested_id.into()); - let (public_key, private_key) = self.keys.into_vecs().ok_or_else(|| { - invalid_js_data("load_signed_pre_key", "Missing public/private key bytes") - })?; - let signature = self - .signature - .map(ByteBuf::into_vec) - .ok_or_else(|| invalid_js_data("load_signed_pre_key", "Missing signature bytes"))?; - let timestamp_ms = self.timestamp.unwrap_or(0); - let normalized_public_key = ensure_curve_key_with_prefix(public_key); - let key_pair = KeyPair::from_public_and_private(&normalized_public_key, &private_key)?; - let timestamp = Timestamp::from_epoch_millis(timestamp_ms); - Ok(SignedPreKeyRecord::new( - SignedPreKeyId::from(effective_id), - timestamp, - &key_pair, - &signature, - )) - } -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -struct JsIdentityKeyPairPayload { - #[serde(flatten)] - keys: JsKeyEnvelope, -} - -impl JsIdentityKeyPairPayload { - fn into_pair(self) -> SignalResult { - let (public_key, private_key) = self.keys.into_vecs().ok_or_else(|| { - invalid_js_data("get_identity_key_pair", "Missing public/private key bytes") - })?; - let normalized_public_key = ensure_curve_key_with_prefix(public_key); - let identity_key = IdentityKey::try_from(normalized_public_key.as_slice())?; - let private_key = PrivateKey::deserialize(&private_key)?; - Ok(IdentityKeyPair::new(identity_key, private_key)) - } -} - fn invalid_js_data(context: &'static str, message: impl Into) -> SignalProtocolError { SignalProtocolError::InvalidState(context, message.into()) } -fn ensure_curve_key_with_prefix(bytes: Vec) -> Vec { - if bytes.len() == 33 && bytes.first().copied() == Some(0x05) { - return bytes; - } - - if bytes.len() == 32 { - let mut prefixed = Vec::with_capacity(33); - prefixed.push(0x05); - prefixed.extend_from_slice(&bytes); - return prefixed; - } - - bytes -} - #[inline] fn js_to_signal_error(e: JsValue) -> libsignal::SignalProtocolError { libsignal::SignalProtocolError::FfiBindingError(format!("{:?}", e)) @@ -470,26 +188,6 @@ async fn resolve_maybe_promise(value: JsValue) -> Result { Ok(value) } -#[inline] -async fn resolve_maybe_promise_optional(value: JsValue) -> SignalResult> { - let resolved = resolve_maybe_promise(value) - .await - .map_err(js_to_signal_error)?; - if resolved.is_null() || resolved.is_undefined() { - Ok(None) - } else { - Ok(Some(resolved)) - } -} - -#[inline] -fn deserialize_js_value( - value: JsValue, - context: &'static str, -) -> SignalResult { - serde_wasm_bindgen::from_value(value).map_err(|err| invalid_js_data(context, err.to_string())) -} - #[inline] fn js_array_to_bytes(array: &js_sys::Array) -> SignalResult> { js_array_to_vec(array).map_err(js_to_signal_error) @@ -575,295 +273,6 @@ fn get_bytes_from_buffer_json(obj: &JsValue, key: &str) -> SignalResult SignalResult> { - let address_str = self.get_address_string(address); - - if let Some(record) = self.cached_sessions.borrow().get(&address_str) { - return Ok(Some(record.clone())); - } - - let result = self - .js_storage - .js_load_session(&address_str) - .map_err(js_to_signal_error)?; - let value = resolve_maybe_promise(result) - .await - .map_err(js_to_signal_error)?; - - if value.is_null() || value.is_undefined() { - return Ok(None); - } - - // Only bytes. A caller holding a pre-WASM JSON record converts it - // through the core's typed model before handing it over; the - // field-by-field fallback that used to live here could not, since it - // stored a message key's seed as its cipher key and zeroed the mac and - // iv, so the first ciphertext the old build enciphered failed its MAC. - match js_value_to_bytes(&value)? { - Some(data) => { - let record = - crate::counter_lease::waive_session(CoreSessionRecord::deserialize(&data)?); - // Insert into cache and return a clone - this is required since HashMap takes ownership - let result = record.clone(); - self.cached_sessions - .borrow_mut() - .insert(address_str, record); - Ok(Some(result)) - } - None => Ok(None), - } - } - - async fn has_session(&self, address: &libsignal::ProtocolAddress) -> SignalResult { - Ok(SessionStore::load_session(self, address).await?.is_some()) - } - - async fn store_session( - &mut self, - address: &libsignal::ProtocolAddress, - record: CoreSessionRecord, - ) -> SignalResult<()> { - let address_str = self.get_address_string(address); - - // Before serializing: a record the protocol built for itself never - // passed the load path, and neither the row nor the cache should carry - // a reservation this crate does not honour. - let record = crate::counter_lease::waive_session(record); - let bytes = record.serialize()?; - - let result = if self.has_store_session_raw() { - let uint8 = Uint8Array::from(bytes.as_slice()); - self.js_storage.js_store_session_raw(&address_str, &uint8) - } else { - let session_record = SessionRecord::new(bytes); - let js_record: JsValue = session_record.into(); - self.js_storage.js_store_session(&address_str, js_record) - }; - - let promise_value = result.map_err(js_to_signal_error)?; - resolve_maybe_promise(promise_value) - .await - .map_err(js_to_signal_error)?; - - self.cached_sessions - .borrow_mut() - .insert(address_str, record); - - Ok(()) - } -} - -#[async_trait(?Send)] -impl IdentityKeyStore for JsStorageAdapter { - async fn get_identity_key_pair(&self) -> SignalResult { - if let Some(pair) = self.cached_identity_key_pair.borrow().as_ref().cloned() { - return Ok(pair); - } - - let result = self - .js_storage - .js_get_our_identity() - .map_err(js_to_signal_error)?; - let value = resolve_maybe_promise_optional(result).await?; - - let js_value = value.ok_or_else(|| { - SignalProtocolError::InvalidState("get_identity_key_pair", "JS returned null".into()) - })?; - - let payload: JsIdentityKeyPairPayload = - deserialize_js_value(js_value, "get_identity_key_pair")?; - let key_pair = payload.into_pair()?; - - self.cached_identity_key_pair - .borrow_mut() - .replace(key_pair.clone()); - - Ok(key_pair) - } - - async fn get_local_registration_id(&self) -> SignalResult { - if let Some(id) = *self.cached_registration_id.borrow() { - return Ok(id); - } - - let result = self - .js_storage - .js_get_our_registration_id() - .map_err(js_to_signal_error)?; - let value = resolve_maybe_promise(result) - .await - .map_err(js_to_signal_error)?; - - let registration = value.as_f64().ok_or_else(|| { - SignalProtocolError::InvalidState( - "get_local_registration_id", - "JS did not return a number".into(), - ) - })? as u32; - - self.cached_registration_id - .borrow_mut() - .replace(registration); - - Ok(registration) - } - - async fn is_trusted_identity( - &self, - address: &libsignal::ProtocolAddress, - identity: &libsignal::IdentityKey, - direction: StoreDirection, - ) -> SignalResult { - let address_name = self.get_address_string(address); - let identity_bytes = identity.serialize(); - - if let Some(cached_key) = self.cached_identities.borrow().get(&address_name) - && cached_key.as_slice() == identity_bytes.as_slice() - { - return Ok(true); - } - - let direction_val = match direction { - StoreDirection::Sending => 0, - StoreDirection::Receiving => 1, - }; - - let uint8 = Uint8Array::from(identity_bytes.as_slice()); - let result = self - .js_storage - .js_is_trusted_identity(&address_name, &uint8, direction_val) - .map_err(js_to_signal_error)?; - - let value = resolve_maybe_promise(result) - .await - .map_err(js_to_signal_error)?; - - let trusted = value.as_bool().unwrap_or(false); - - Ok(trusted) - } - - // Identity rows are keyed by the full address, matching their session. A - // pre-fix store holds them under the bare user, and those rows are simply - // left behind: they are not read again, and re-learning an identity is - // harmless under trust-on-first-use, whereas deleting rows on upgrade risks - // dropping one that is still in use. - async fn save_identity( - &mut self, - address: &libsignal::ProtocolAddress, - identity: &libsignal::IdentityKey, - ) -> SignalResult { - // Identity records are keyed by the SAME address as the session they - // belong to. Using `name()` here drops the device id, which both keys - // the record differently from `load_session`/`store_session` AND makes - // the transaction layer lock a different id — so an identity write no - // longer serializes against the encrypt/decrypt touching that session. - let address_name = self.get_address_string(address); - let identity_bytes = identity.serialize(); - - let previous_identity = self.load_peer_identity(&address_name).await?; - let changed = previous_identity - .as_deref() - .is_some_and(|stored| stored != identity_bytes.as_slice()); - - if self.has_js_method("saveIdentity") { - let uint8 = Uint8Array::from(identity_bytes.as_slice()); - let result = self - .js_storage - .js_save_identity(&address_name, &uint8) - .map_err(js_to_signal_error)?; - resolve_maybe_promise(result) - .await - .map_err(js_to_signal_error)?; - } - - self.cached_identities - .borrow_mut() - .insert(address_name, identity_bytes.to_vec()); - - Ok(IdentityChange::from_changed(changed)) - } - - async fn get_identity( - &self, - address: &libsignal::ProtocolAddress, - ) -> SignalResult> { - let address_name = self.get_address_string(address); - self.load_peer_identity(&address_name) - .await? - .map(|identity| libsignal::IdentityKey::decode(&identity)) - .transpose() - } -} - -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl PreKeyStore for JsStorageAdapter { - async fn get_pre_key(&self, prekey_id: PreKeyId) -> SignalResult { - let result = self - .js_storage - .js_load_pre_key(prekey_id.into()) - .map_err(js_to_signal_error)?; - let value = resolve_maybe_promise_optional(result).await?; - - let js_value = value.ok_or(SignalProtocolError::InvalidPreKeyId)?; - let payload: JsPreKeyRecordPayload = deserialize_js_value(js_value, "load_pre_key")?; - payload.into_record(prekey_id) - } - - async fn save_pre_key( - &mut self, - _prekey_id: PreKeyId, - _record: &PreKeyRecord, - ) -> SignalResult<()> { - Ok(()) - } - - async fn remove_pre_key(&mut self, prekey_id: PreKeyId) -> SignalResult<()> { - let result = self - .js_storage - .js_remove_pre_key(prekey_id.into()) - .map_err(js_to_signal_error)?; - resolve_maybe_promise(result) - .await - .map_err(js_to_signal_error)?; - Ok(()) - } -} - -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl SignedPreKeyStore for JsStorageAdapter { - async fn get_signed_pre_key( - &self, - signed_prekey_id: SignedPreKeyId, - ) -> SignalResult { - let result = self - .js_storage - .js_load_signed_pre_key(signed_prekey_id.into()) - .map_err(js_to_signal_error)?; - let value = resolve_maybe_promise_optional(result).await?; - - let js_value = value.ok_or(SignalProtocolError::InvalidSignedPreKeyId)?; - let payload: JsSignedPreKeyRecordPayload = - deserialize_js_value(js_value, "load_signed_pre_key")?; - payload.into_record(signed_prekey_id) - } - - async fn save_signed_pre_key( - &mut self, - _id: SignedPreKeyId, - _record: &SignedPreKeyRecord, - ) -> SignalResult<()> { - Ok(()) - } -} - #[async_trait(?Send)] impl SenderKeyStore for JsStorageAdapter { async fn load_sender_key( diff --git a/packages/whatsapp-rust-bridge/test/session_builder.test.ts b/packages/whatsapp-rust-bridge/test/session_builder.test.ts deleted file mode 100644 index 67be9e7cff0..00000000000 --- a/packages/whatsapp-rust-bridge/test/session_builder.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect } from "@jest/globals"; - -import { - generateIdentityKeyPair, - generatePreKey, - generateRegistrationId, - generateSignedPreKey, - ProtocolAddress, - SessionBuilder, -} from "../dist/index.js"; -import { FakeStorage } from "./helpers/fake_storage"; - -class AlwaysTrustStorage extends FakeStorage { - override async isTrustedIdentity(): Promise { - return true; - } -} - -function makeBundle(identityKeyPair = generateIdentityKeyPair()) { - const signedPreKey = generateSignedPreKey(identityKeyPair, 1); - const preKey = generatePreKey(22); - - return { - identityKeyPair, - bundle: { - registrationId: generateRegistrationId(), - identityKey: identityKeyPair.pubKey, - signedPreKey: { - keyId: signedPreKey.keyId, - publicKey: signedPreKey.keyPair.pubKey, - signature: signedPreKey.signature, - }, - preKey: { - keyId: preKey.keyId, - publicKey: preKey.keyPair.pubKey, - }, - }, - }; -} - -describe("SessionBuilder", () => { - it("should successfully process a pre-key bundle and create a new session", async () => { - const aliceStorage = new FakeStorage(); - const bobAddress = new ProtocolAddress("bob", 1); - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - - const bobIdentityKeyPair = generateIdentityKeyPair(); - const bobRegistrationId = generateRegistrationId(); - const bobSignedPreKeyId = 1337; - const bobSignedPreKey = generateSignedPreKey( - bobIdentityKeyPair, - bobSignedPreKeyId - ); - const bobPreKeyId = 22; - const bobPreKey = generatePreKey(bobPreKeyId); - - const bobBundle = { - registrationId: bobRegistrationId, - identityKey: bobIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - preKey: { - keyId: bobPreKey.keyId, - publicKey: bobPreKey.keyPair.pubKey, - }, - }; - - await aliceSessionBuilder.processPreKeyBundle(bobBundle); - - const sessionForBob = aliceStorage.getSession(bobAddress.toString()); - - expect(sessionForBob).toBeDefined(); - expect(sessionForBob).toBeInstanceOf(Uint8Array); - expect(sessionForBob!.length).toBeGreaterThan(100); - - const isTrusted = await aliceStorage.isTrustedIdentity( - "bob", - bobIdentityKeyPair.pubKey, - 0 - ); - expect(isTrusted).toBe(true); - expect(aliceStorage.getIdentity("bob.1")).toEqual(bobIdentityKeyPair.pubKey); - expect(aliceStorage.identityLoadCount).toBeGreaterThan(0); - expect(aliceStorage.identitySaveCount).toBeGreaterThan(0); - }); - - it("should persist peer identities across adapter instances", async () => { - const storage = new AlwaysTrustStorage(); - const bobAddress = new ProtocolAddress("bob-persisted", 1); - const first = makeBundle(); - - await new SessionBuilder(storage, bobAddress).processPreKeyBundle( - first.bundle - ); - expect(storage.getIdentity("bob-persisted.1")).toEqual( - first.identityKeyPair.pubKey - ); - - const second = makeBundle(); - await new SessionBuilder(storage, bobAddress).processPreKeyBundle( - second.bundle - ); - - expect(storage.getIdentity("bob-persisted.1")).toEqual( - second.identityKeyPair.pubKey - ); - expect(storage.identityLoadCount).toBeGreaterThanOrEqual(2); - expect(storage.identitySaveCount).toBeGreaterThanOrEqual(2); - }); - - it("should throw an error for an untrusted identity", async () => { - const aliceStorage = new FakeStorage(); - const bobAddress = new ProtocolAddress("bob", 1); - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - - const bobIdentityKeyPair = generateIdentityKeyPair(); - const bobSignedPreKey = generateSignedPreKey(bobIdentityKeyPair, 1); - - const fakeIdentity = generateIdentityKeyPair(); - aliceStorage.trustIdentity("bob.1", fakeIdentity.pubKey); - - const bobBundle = { - registrationId: 1234, - identityKey: bobIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - }; - - await expect( - aliceSessionBuilder.processPreKeyBundle(bobBundle) - ).rejects.toEqual( - expect.stringContaining("untrusted identity for address bob.1") - ); - }); -}); diff --git a/packages/whatsapp-rust-bridge/test/session_cipher.test.ts b/packages/whatsapp-rust-bridge/test/session_cipher.test.ts deleted file mode 100644 index 214356b1b59..00000000000 --- a/packages/whatsapp-rust-bridge/test/session_cipher.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { describe, it, expect } from "@jest/globals"; -import { - ProtocolAddress, - SessionBuilder, - SessionCipher, - generateSignedPreKey, - generatePreKey, -} from "../dist/index.js"; -import { FakeStorage } from "./helpers/fake_storage"; - -describe("SessionCipher end-to-end", () => { - it("should establish a session and exchange messages correctly", async () => { - // === 1. SETUP === - // Create Alice and Bob with their own storage. - const aliceStorage = new FakeStorage(); - const bobStorage = new FakeStorage(); - - // Define their addresses. - const aliceAddress = new ProtocolAddress("alice", 1); - const bobAddress = new ProtocolAddress("bob", 1); - - // Alice needs to trust Bob's identity key, and vice versa. - // This simulates fetching the key from a server and verifying it. - aliceStorage.trustIdentity("bob.1", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice.1", aliceStorage.ourIdentityKeyPair.pubKey); - - // === 2. BOB'S PRE-KEY BUNDLE === - // Bob generates his keys and "uploads" them to the server (i.e., we store them in his storage). - const bobSignedPreKeyId = 1; - const bobSignedPreKey = generateSignedPreKey( - bobStorage.ourIdentityKeyPair, - bobSignedPreKeyId, - ); - const bobOneTimePreKey = generatePreKey(100); - - bobStorage.storeSignedPreKey(bobSignedPreKey.keyId, bobSignedPreKey); - bobStorage.storePreKey(bobOneTimePreKey.keyId, bobOneTimePreKey.keyPair); - - // Alice "downloads" Bob's bundle. - const bobBundle = { - registrationId: bobStorage.ourRegistrationId, - identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - preKey: { - keyId: bobOneTimePreKey.keyId, - publicKey: bobOneTimePreKey.keyPair.pubKey, - }, - }; - - // === 3. ALICE BUILDS THE SESSION === - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - await aliceSessionBuilder.processPreKeyBundle(bobBundle); - - // === 4. ALICE SENDS THE FIRST MESSAGE === - const aliceCipher = new SessionCipher(aliceStorage, bobAddress); - const plaintext1 = Buffer.from("Hello Bob, this is Alice!"); - const encryptedMessageForBob = await aliceCipher.encrypt(plaintext1); - - // The first message is always a PreKeyWhisperMessage (type 3) - expect(encryptedMessageForBob.type).toBe(3); - - // === 5. BOB RECEIVES AND DECRYPTS THE FIRST MESSAGE === - const bobCipher = new SessionCipher(bobStorage, aliceAddress); - const decryptedByBob = await bobCipher.decryptPreKeyWhisperMessage( - encryptedMessageForBob.body, - ); - - expect(Buffer.from(decryptedByBob)).toEqual(plaintext1); - - // Check that Bob's one-time pre-key was used and removed. - const usedPreKey = await bobStorage.loadPreKey(bobOneTimePreKey.keyId); - expect(usedPreKey).toBeUndefined(); - - // === 6. BOB SENDS A REPLY === - const plaintext2 = Buffer.from("Hey Alice, I got your message!"); - const encryptedMessageForAlice = await bobCipher.encrypt(plaintext2); - - expect(encryptedMessageForAlice.type).toBe(2); - - // === 7. ALICE RECEIVES AND DECRYPTS THE REPLY === - const decryptedByAlice = await aliceCipher.decryptWhisperMessage( - encryptedMessageForAlice.body, - ); - - expect(Buffer.from(decryptedByAlice)).toEqual(plaintext2); - - console.log("✅ Full session flow test passed!"); - }); - - it("should treat Baileys-style plain session objects as missing sessions", async () => { - const aliceStorage = new FakeStorage(); - const bobStorage = new FakeStorage(); - - const aliceAddress = new ProtocolAddress("alice", 1); - const bobAddress = new ProtocolAddress("bob", 1); - - aliceStorage.trustIdentity("bob.1", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice.1", aliceStorage.ourIdentityKeyPair.pubKey); - - const bobSignedPreKeyId = 7; - const bobSignedPreKey = generateSignedPreKey( - bobStorage.ourIdentityKeyPair, - bobSignedPreKeyId, - ); - const bobOneTimePreKey = generatePreKey(701); - - bobStorage.storeSignedPreKey(bobSignedPreKey.keyId, bobSignedPreKey); - bobStorage.storePreKey(bobOneTimePreKey.keyId, bobOneTimePreKey.keyPair); - - const bobBundle = { - registrationId: bobStorage.ourRegistrationId, - identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - preKey: { - keyId: bobOneTimePreKey.keyId, - publicKey: bobOneTimePreKey.keyPair.pubKey, - }, - }; - - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - await aliceSessionBuilder.processPreKeyBundle(bobBundle); - - const aliceCipher = new SessionCipher(aliceStorage, bobAddress); - const plaintext = Buffer.from("Trigger broken loadSession path"); - const encryptedMessageForBob = await aliceCipher.encrypt(plaintext); - - // Simulate a Baileys-style storage adapter that returns a raw object for a fresh session. - const originalLoadSession = bobStorage.loadSession.bind(bobStorage); - bobStorage.loadSession = (async (address: string) => { - const existing = await originalLoadSession(address); - if (existing) { - return existing; - } - return { _sessions: {}, version: "v1" }; - }) as any; - - const bobCipher = new SessionCipher(bobStorage, aliceAddress); - - // With the fix, the legacy JSON is treated as an empty session. - // Since this is a PreKeyWhisperMessage, it establishes a new session. - // So decryption should SUCCEED (self-healing). - const decrypted = await bobCipher.decryptPreKeyWhisperMessage( - encryptedMessageForBob.body, - ); - expect(Buffer.from(decrypted)).toEqual(plaintext); - }); -}); diff --git a/packages/whatsapp-rust-bridge/test/simultaneous_session.test.ts b/packages/whatsapp-rust-bridge/test/simultaneous_session.test.ts deleted file mode 100644 index a439236f385..00000000000 --- a/packages/whatsapp-rust-bridge/test/simultaneous_session.test.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, it, expect } from "@jest/globals"; -import { - ProtocolAddress, - SessionBuilder, - SessionCipher, - generateSignedPreKey, - generatePreKey, -} from "../dist/index.js"; -import { FakeStorage } from "./helpers/fake_storage"; - -describe("Simultaneous Session Initiation (Race Condition)", () => { - it("should handle both sides initiating sessions simultaneously", async () => { - const aliceStorage = new FakeStorage(); - const bobStorage = new FakeStorage(); - - const aliceAddress = new ProtocolAddress("alice", 1); - const bobAddress = new ProtocolAddress("bob", 1); - - aliceStorage.trustIdentity("bob", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", aliceStorage.ourIdentityKeyPair.pubKey); - - const aliceSignedPreKey = generateSignedPreKey( - aliceStorage.ourIdentityKeyPair, - 1 - ); - const aliceOneTimePreKey = generatePreKey(100); - aliceStorage.storeSignedPreKey(aliceSignedPreKey.keyId, aliceSignedPreKey); - aliceStorage.storePreKey( - aliceOneTimePreKey.keyId, - aliceOneTimePreKey.keyPair - ); - - const aliceBundle = { - registrationId: aliceStorage.ourRegistrationId, - identityKey: aliceStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: aliceSignedPreKey.keyId, - publicKey: aliceSignedPreKey.keyPair.pubKey, - signature: aliceSignedPreKey.signature, - }, - preKey: { - keyId: aliceOneTimePreKey.keyId, - publicKey: aliceOneTimePreKey.keyPair.pubKey, - }, - }; - - const bobSignedPreKey = generateSignedPreKey( - bobStorage.ourIdentityKeyPair, - 1 - ); - const bobOneTimePreKey = generatePreKey(200); - bobStorage.storeSignedPreKey(bobSignedPreKey.keyId, bobSignedPreKey); - bobStorage.storePreKey(bobOneTimePreKey.keyId, bobOneTimePreKey.keyPair); - - const bobBundle = { - registrationId: bobStorage.ourRegistrationId, - identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - preKey: { - keyId: bobOneTimePreKey.keyId, - publicKey: bobOneTimePreKey.keyPair.pubKey, - }, - }; - - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - const bobSessionBuilder = new SessionBuilder(bobStorage, aliceAddress); - - await aliceSessionBuilder.processPreKeyBundle(bobBundle); - await bobSessionBuilder.processPreKeyBundle(aliceBundle); - - const aliceCipher = new SessionCipher(aliceStorage, bobAddress); - const bobCipher = new SessionCipher(bobStorage, aliceAddress); - - const alicePlaintext = Buffer.from("Hello from Alice!"); - const bobPlaintext = Buffer.from("Hello from Bob!"); - - const aliceEncrypted = await aliceCipher.encrypt(alicePlaintext); - const bobEncrypted = await bobCipher.encrypt(bobPlaintext); - - expect(aliceEncrypted.type).toBe(3); - expect(bobEncrypted.type).toBe(3); - - console.log("Alice encrypted message type:", aliceEncrypted.type); - console.log("Bob encrypted message type:", bobEncrypted.type); - console.log("Alice ciphertext length:", aliceEncrypted.body.length); - console.log("Bob ciphertext length:", bobEncrypted.body.length); - - const decryptedByBob = await bobCipher.decryptPreKeyWhisperMessage( - aliceEncrypted.body - ); - console.log( - "Bob decrypted Alice's message:", - Buffer.from(decryptedByBob).toString() - ); - expect(Buffer.from(decryptedByBob)).toEqual(alicePlaintext); - - const decryptedByAlice = await aliceCipher.decryptPreKeyWhisperMessage( - bobEncrypted.body - ); - console.log( - "Alice decrypted Bob's message:", - Buffer.from(decryptedByAlice).toString() - ); - expect(Buffer.from(decryptedByAlice)).toEqual(bobPlaintext); - - console.log("✅ Simultaneous session initiation test passed!"); - }); - - it("should handle session injection followed by incoming PreKey message", async () => { - const baileysStorage = new FakeStorage(); - const device5Storage = new FakeStorage(); - - const baileysAddress = new ProtocolAddress("baileys", 6); - const device5Address = new ProtocolAddress("device5", 5); - - baileysStorage.trustIdentity( - "device5", - device5Storage.ourIdentityKeyPair.pubKey - ); - device5Storage.trustIdentity( - "baileys", - baileysStorage.ourIdentityKeyPair.pubKey - ); - - const baileysSignedPreKey = generateSignedPreKey( - baileysStorage.ourIdentityKeyPair, - 1 - ); - const baileysOneTimePreKey = generatePreKey(100); - baileysStorage.storeSignedPreKey( - baileysSignedPreKey.keyId, - baileysSignedPreKey - ); - baileysStorage.storePreKey( - baileysOneTimePreKey.keyId, - baileysOneTimePreKey.keyPair - ); - - const baileysBundle = { - registrationId: baileysStorage.ourRegistrationId, - identityKey: baileysStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: baileysSignedPreKey.keyId, - publicKey: baileysSignedPreKey.keyPair.pubKey, - signature: baileysSignedPreKey.signature, - }, - preKey: { - keyId: baileysOneTimePreKey.keyId, - publicKey: baileysOneTimePreKey.keyPair.pubKey, - }, - }; - - const device5SignedPreKey = generateSignedPreKey( - device5Storage.ourIdentityKeyPair, - 1 - ); - const device5OneTimePreKey = generatePreKey(200); - device5Storage.storeSignedPreKey( - device5SignedPreKey.keyId, - device5SignedPreKey - ); - device5Storage.storePreKey( - device5OneTimePreKey.keyId, - device5OneTimePreKey.keyPair - ); - - const device5Bundle = { - registrationId: device5Storage.ourRegistrationId, - identityKey: device5Storage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: device5SignedPreKey.keyId, - publicKey: device5SignedPreKey.keyPair.pubKey, - signature: device5SignedPreKey.signature, - }, - preKey: { - keyId: device5OneTimePreKey.keyId, - publicKey: device5OneTimePreKey.keyPair.pubKey, - }, - }; - - const device5SessionBuilder = new SessionBuilder( - device5Storage, - baileysAddress - ); - await device5SessionBuilder.processPreKeyBundle(baileysBundle); - - const device5Cipher = new SessionCipher(device5Storage, baileysAddress); - const device5Plaintext = Buffer.from("Hello from Device5!"); - const device5Encrypted = await device5Cipher.encrypt(device5Plaintext); - - console.log("Device5 encrypted message type:", device5Encrypted.type); - console.log("Device5 ciphertext length:", device5Encrypted.body.length); - - const baileysSessionBuilder = new SessionBuilder( - baileysStorage, - device5Address - ); - await baileysSessionBuilder.processPreKeyBundle(device5Bundle); - - const baileysCipher = new SessionCipher(baileysStorage, device5Address); - const baileysPlaintext = Buffer.from("Hello from Baileys!"); - const baileysEncrypted = await baileysCipher.encrypt(baileysPlaintext); - - console.log("Baileys encrypted message type:", baileysEncrypted.type); - - try { - const decryptedByBaileys = - await baileysCipher.decryptPreKeyWhisperMessage(device5Encrypted.body); - console.log( - "Baileys decrypted Device5's message:", - Buffer.from(decryptedByBaileys).toString() - ); - expect(Buffer.from(decryptedByBaileys)).toEqual(device5Plaintext); - console.log("✅ Session injection + incoming PreKey test passed!"); - } catch (error) { - console.error("❌ Failed to decrypt Device5's message:", error); - throw error; - } - - const decryptedByDevice5 = await device5Cipher.decryptPreKeyWhisperMessage( - baileysEncrypted.body - ); - console.log( - "Device5 decrypted Baileys' message:", - Buffer.from(decryptedByDevice5).toString() - ); - expect(Buffer.from(decryptedByDevice5)).toEqual(baileysPlaintext); - }); - - it("initOutgoing should NOT overwrite existing session", async () => { - const aliceStorage = new FakeStorage(); - const bobStorage = new FakeStorage(); - - const aliceAddress = new ProtocolAddress("alice", 1); - const bobAddress = new ProtocolAddress("bob", 1); - - aliceStorage.trustIdentity("bob", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", aliceStorage.ourIdentityKeyPair.pubKey); - - const bobSignedPreKey = generateSignedPreKey( - bobStorage.ourIdentityKeyPair, - 1 - ); - const bobOneTimePreKey = generatePreKey(300); - bobStorage.storeSignedPreKey(bobSignedPreKey.keyId, bobSignedPreKey); - bobStorage.storePreKey(bobOneTimePreKey.keyId, bobOneTimePreKey.keyPair); - - const bobBundle = { - registrationId: bobStorage.ourRegistrationId, - identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey.keyId, - publicKey: bobSignedPreKey.keyPair.pubKey, - signature: bobSignedPreKey.signature, - }, - preKey: { - keyId: bobOneTimePreKey.keyId, - publicKey: bobOneTimePreKey.keyPair.pubKey, - }, - }; - - const aliceSessionBuilder = new SessionBuilder(aliceStorage, bobAddress); - await aliceSessionBuilder.processPreKeyBundle(bobBundle); - - const aliceCipher = new SessionCipher(aliceStorage, bobAddress); - const plaintext1 = Buffer.from("First message from Alice"); - const encrypted1 = await aliceCipher.encrypt(plaintext1); - expect(encrypted1.type).toBe(3); - - const bobCipher = new SessionCipher(bobStorage, aliceAddress); - const decrypted1 = await bobCipher.decryptPreKeyWhisperMessage( - encrypted1.body - ); - expect(Buffer.from(decrypted1)).toEqual(plaintext1); - - const bobReply = Buffer.from("Reply from Bob"); - const encryptedBobReply = await bobCipher.encrypt(bobReply); - expect(encryptedBobReply.type).toBe(2); - - const decryptedBobReply = await aliceCipher.decryptWhisperMessage( - encryptedBobReply.body - ); - expect(Buffer.from(decryptedBobReply)).toEqual(bobReply); - - const plaintext2 = Buffer.from("Second message from Alice"); - const encrypted2 = await aliceCipher.encrypt(plaintext2); - expect(encrypted2.type).toBe(2); - - const bobSignedPreKey2 = generateSignedPreKey( - bobStorage.ourIdentityKeyPair, - 2 - ); - const bobOneTimePreKey2 = generatePreKey(400); - bobStorage.storeSignedPreKey(bobSignedPreKey2.keyId, bobSignedPreKey2); - bobStorage.storePreKey(bobOneTimePreKey2.keyId, bobOneTimePreKey2.keyPair); - - const bobBundle2 = { - registrationId: bobStorage.ourRegistrationId, - identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: bobSignedPreKey2.keyId, - publicKey: bobSignedPreKey2.keyPair.pubKey, - signature: bobSignedPreKey2.signature, - }, - preKey: { - keyId: bobOneTimePreKey2.keyId, - publicKey: bobOneTimePreKey2.keyPair.pubKey, - }, - }; - - const aliceSessionBuilder2 = new SessionBuilder(aliceStorage, bobAddress); - await aliceSessionBuilder2.initOutgoing(bobBundle2); - - const plaintext3 = Buffer.from("Third message after initOutgoing call"); - const encrypted3 = await aliceCipher.encrypt(plaintext3); - - console.log("Message type after initOutgoing:", encrypted3.type); - expect(encrypted3.type).toBe(2); - - const decrypted2 = await bobCipher.decryptWhisperMessage(encrypted2.body); - expect(Buffer.from(decrypted2)).toEqual(plaintext2); - - const decrypted3 = await bobCipher.decryptWhisperMessage(encrypted3.body); - expect(Buffer.from(decrypted3)).toEqual(plaintext3); - - console.log("✅ initOutgoing correctly preserved existing session!"); - }); -}); diff --git a/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts index 4bc56e015ac..a1dc82c96ac 100644 --- a/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts +++ b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "@jest/globals"; import { + processBundleWithSnapshot, ProtocolAddress, - SessionBuilder, decryptPreKeyWithSnapshot, decryptWhisperWithSnapshot, encryptWithSnapshot, @@ -76,15 +76,9 @@ function snapshotOf( /** Opens a session from alice towards bob using the callback API (setup only). */ async function establish(alice: Party, bob: Party, bobAddr: ProtocolAddress) { - const storage = new FakeStorage(); - storage.ourIdentityKeyPair = { - pubKey: prefixed(alice.identity.public), - privKey: alice.identity.private, - }; - storage.ourRegistrationId = alice.registrationId; - - const builder = new SessionBuilder(storage as never, bobAddr); - await builder.initOutgoing({ + // The same call the consumer makes; SessionBuilder is gone with the rest of + // the callback-based session path. + const out = await processBundleWithSnapshot(snapshotOf(alice), bobAddr, { registrationId: bob.registrationId, identityKey: prefixed(bob.identity.public), preKey: { keyId: 1, publicKey: prefixed(bob.preKey.keyPair.pubKey) }, @@ -93,9 +87,9 @@ async function establish(alice: Party, bob: Party, bobAddr: ProtocolAddress) { publicKey: prefixed(bob.signedPreKey.keyPair.pubKey), signature: bob.signedPreKey.signature, }, - }); + } as never); - const session = await storage.loadSession(bobAddr.toString()); + const session = out.changes.session; if (!session) throw new Error("session was not established"); return session as Uint8Array; } @@ -212,6 +206,108 @@ describe("snapshot API", () => { expect(new Set(produced).size).toBe(5); }); + // The three cases below replace what the removed SessionBuilder and + // SessionCipher suites covered. They are the same scenarios expressed + // against the snapshot calls, which is the only session API left. + + it("does not discard a live session when the peer's bundle is processed again", async () => { + const alice = makeParty(); + const bob = makeParty(); + const session = await establish(alice, bob, bobAddr()); + + const first = await encryptWithSnapshot( + snapshotOf(alice, { session }), + bobAddr(), + new TextEncoder().encode("first"), + ); + + // A second bundle for the same peer, the way a re-fetch delivers one. + const again = await processBundleWithSnapshot( + snapshotOf(alice, { session: first.changes.session }), + bobAddr(), + { + registrationId: bob.registrationId, + identityKey: prefixed(bob.identity.public), + preKey: { keyId: 1, publicKey: prefixed(bob.preKey.keyPair.pubKey) }, + signedPreKey: { + keyId: 1, + publicKey: prefixed(bob.signedPreKey.keyPair.pubKey), + signature: bob.signedPreKey.signature, + }, + } as never, + ); + + // Bob must still be able to read the message sent under the old session, + // which is what breaks if processing a bundle replaces it outright. + const received = await decryptPreKeyWithSnapshot( + snapshotOf(bob), + aliceAddr(), + first.ciphertext, + ); + + expect(new TextDecoder().decode(received.plaintext)).toBe("first"); + expect(again.changes.session).toBeDefined(); + }); + + it("converges when both sides open a session at the same time", async () => { + const alice = makeParty(); + const bob = makeParty(); + + // Neither has heard from the other yet, so both build from a bundle. + const aliceSession = await establish(alice, bob, bobAddr()); + const bobSession = await establish(bob, alice, aliceAddr()); + + const fromAlice = await encryptWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + new TextEncoder().encode("from alice"), + ); + const fromBob = await encryptWithSnapshot( + snapshotOf(bob, { session: bobSession }), + aliceAddr(), + new TextEncoder().encode("from bob"), + ); + + // Each side decrypts the other's opener against its own pending session. + const atBob = await decryptPreKeyWithSnapshot( + snapshotOf(bob, { session: bobSession }), + aliceAddr(), + fromAlice.ciphertext, + ); + const atAlice = await decryptPreKeyWithSnapshot( + snapshotOf(alice, { session: aliceSession }), + bobAddr(), + fromBob.ciphertext, + ); + + expect(new TextDecoder().decode(atBob.plaintext)).toBe("from alice"); + expect(new TextDecoder().decode(atAlice.plaintext)).toBe("from bob"); + }); + + it("accepts an incoming prekey message after a bundle was injected", async () => { + const alice = makeParty(); + const bob = makeParty(); + + // Alice injects bob's bundle, then bob's own opener arrives before she + // ever sent anything: the injected session must not shut that out. + const injected = await establish(alice, bob, bobAddr()); + const bobSession = await establish(bob, alice, aliceAddr()); + const opener = await encryptWithSnapshot( + snapshotOf(bob, { session: bobSession }), + aliceAddr(), + new TextEncoder().encode("opener"), + ); + + const received = await decryptPreKeyWithSnapshot( + snapshotOf(alice, { session: injected }), + bobAddr(), + opener.ciphertext, + ); + + expect(new TextDecoder().decode(received.plaintext)).toBe("opener"); + expect(received.changes.session).toBeDefined(); + }); + it("does not mutate the snapshot it was given", async () => { const alice = makeParty(); const bob = makeParty(); diff --git a/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts b/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts index 713b0929378..8e9e54be6e9 100644 --- a/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts +++ b/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts @@ -1,191 +1,29 @@ import { describe, it, expect } from "@jest/globals"; -import { - GroupSessionBuilder, - ProtocolAddress, - SenderKeyName, - SessionBuilder, - SessionCipher, - SessionRecord, - generateIdentityKeyPair, - generatePreKey, - generateRegistrationId, - generateSignedPreKey, -} from "../dist/index.js"; +import { GroupSessionBuilder, ProtocolAddress, SenderKeyName } from "../dist/index.js"; import { FakeStorage } from "./helpers/fake_storage"; class RejectingPersistenceStorage extends FakeStorage { - public failSessionStore = true; public failSenderKeyStore = true; - public failIdentityStore = false; - public sessionLoadCount = 0; public senderKeyLoadCount = 0; - override async loadSession(address: string): Promise { - this.sessionLoadCount += 1; - return super.loadSession(address); - } - - override async storeSessionRaw( - address: string, - data: Uint8Array - ): Promise { - if (this.failSessionStore) { - throw new Error("session persistence failed"); - } - await super.storeSessionRaw(address, data); - } - override async loadSenderKey(keyId: string): Promise { this.senderKeyLoadCount += 1; return super.loadSenderKey(keyId); } - override async storeSenderKey( - keyId: string, - record: Uint8Array - ): Promise { - if (this.failSenderKeyStore) { - throw new Error("sender-key persistence failed"); - } + override async storeSenderKey(keyId: string, record: Uint8Array): Promise { + if (this.failSenderKeyStore) throw new Error("sender-key persistence failed"); await super.storeSenderKey(keyId, record); } - - override async saveIdentity( - identifier: string, - identityKey: Uint8Array - ): Promise { - if (this.failIdentityStore) { - throw new Error("identity persistence failed"); - } - return super.saveIdentity(identifier, identityKey); - } -} - -function makePreKeyBundle() { - const identity = generateIdentityKeyPair(); - const signedPreKey = generateSignedPreKey(identity, 1); - const preKey = generatePreKey(2); - - return { - registrationId: generateRegistrationId(), - identityKey: identity.pubKey, - signedPreKey: { - keyId: signedPreKey.keyId, - publicKey: signedPreKey.keyPair.pubKey, - signature: signedPreKey.signature, - }, - preKey: { - keyId: preKey.keyId, - publicKey: preKey.keyPair.pubKey, - }, - }; } +// The adapter only backs the group path now: the session, identity and pre-key +// stores went with the callback-based session API, which the snapshot calls +// replaced. What is left to pin is that a failed write is not cached as if it +// had succeeded. describe("StorageAdapter Interop", () => { const aliceAddress = new ProtocolAddress("alice", 1); - it("should gracefully handle legacy JSON session objects by treating them as empty", async () => { - const storage = new FakeStorage(); - - // Mock data structure mimicked from libsignal-node - const legacyJson = { - _sessions: { - "BXqk9qn8...": { - registrationId: 123, - currentRatchet: {}, - indexInfo: { - baseKey: "BXqk9qn8...", - baseKeyType: 2, - closed: -1, - }, - _chains: {}, - }, - }, - version: "v1", - }; - - // Override loadSession to return the legacy object directly - // @ts-ignore - storage.loadSession = async () => legacyJson; - - const cipher = new SessionCipher(storage, aliceAddress); - - // The previous crash was: "error while invoking an ffi callback: JsValue(Object(...))" - // We expect the Rust adapter to now detect the object, return an empty session, - // and then the Cipher logic simply complains that there's no open session. - try { - await cipher.encrypt(new Uint8Array([1, 2, 3])); - throw new Error("Should have thrown a logic error (No open session)"); - } catch (e: any) { - const msg = e.toString(); - // Ensure it's NOT the FFI crash - expect(msg).not.toContain("error while invoking an ffi callback"); - expect(msg).not.toContain("JsValue(Object"); - - // It usually throws a generic string error from Rust like "No open session" or "No session record" - // The exact message depends on wacore, but ensuring it's not the crash is sufficient. - } - }); - - it("should correctly load Buffer-like objects { type: 'Buffer', data: [...] }", async () => { - const storage = new FakeStorage(); - - // 1. Create a valid (empty) session to serialize so we have valid protobuf bytes - // Use deserialize with empty array to get a valid empty session - const record = SessionRecord.deserialize(new Uint8Array([])); - const validBytes = record.serialize(); - - // 2. Wrap it in the Buffer-like structure common in JSON DBs (lowdb) - const bufferLike = { - type: "Buffer", - data: Array.from(validBytes), - }; - - // @ts-ignore - storage.loadSession = async () => bufferLike; - - const cipher = new SessionCipher(storage, aliceAddress); - - try { - await cipher.encrypt(new Uint8Array([1])); - throw new Error("Should have thrown a logic error"); - } catch (e: any) { - const msg = e.toString(); - expect(msg).not.toContain("error while invoking an ffi callback"); - // Should not fail with protobuf parsing error if it converted correctly - expect(msg).not.toContain("Protobuf"); - expect(msg).not.toContain("invalid wire type"); - } - }); - - it("should reject invalid byte arrays returned by storage", async () => { - const storage = new FakeStorage(); - storage.loadSession = async () => - [1, "invalid", 3] as unknown as Uint8Array; - - const cipher = new SessionCipher(storage, aliceAddress); - await expect(cipher.hasOpenSession()).rejects.toEqual( - expect.stringContaining("Invalid byte") - ); - }); - - it("should not cache a session when persistence rejects", async () => { - const storage = new RejectingPersistenceStorage(); - const builder = new SessionBuilder(storage, aliceAddress); - const bundle = makePreKeyBundle(); - - await expect(builder.processPreKeyBundle(bundle)).rejects.toEqual( - expect.stringContaining("session persistence failed") - ); - expect(storage.sessionLoadCount).toBe(1); - - storage.failSessionStore = false; - await builder.processPreKeyBundle(bundle); - - expect(storage.sessionLoadCount).toBe(2); - expect(storage.getSession(aliceAddress.toString())).toBeDefined(); - }); - it("should not cache a sender key when persistence rejects", async () => { const storage = new RejectingPersistenceStorage(); const builder = new GroupSessionBuilder(storage); @@ -202,25 +40,4 @@ describe("StorageAdapter Interop", () => { expect(storage.senderKeyLoadCount).toBe(2); expect(storage.senderKeys.get(senderKeyName.toString())).toBeDefined(); }); - - it("should not cache a peer identity when persistence rejects", async () => { - const storage = new RejectingPersistenceStorage(); - storage.failIdentityStore = true; - const builder = new SessionBuilder(storage, aliceAddress); - const bundle = makePreKeyBundle(); - - await expect(builder.processPreKeyBundle(bundle)).rejects.toEqual( - expect.stringContaining("identity persistence failed") - ); - expect(storage.identityLoadCount).toBe(1); - // Identity is keyed by the full address, same as the session it belongs to. - expect(storage.getIdentity("alice.1")).toBeUndefined(); - - storage.failIdentityStore = false; - storage.failSessionStore = false; - await builder.processPreKeyBundle(bundle); - - expect(storage.identityLoadCount).toBe(2); - expect(storage.getIdentity("alice.1")).toEqual(bundle.identityKey); - }); }); From 4abc40050953d92e937057285785d4ef289aa83f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 23:27:21 -0300 Subject: [PATCH 63/71] test: shrink the legacy fixture and record the wire parity vectors The rc.9 fixture was 2,270 lines of pretty-printed JSON, most of it whitespace around base64. Minified and stripped of the four fields no test reads, it is one line and 10,878 bytes, down from 27,637. The three wire parity suites no longer import baileys@7.0.0-rc.9 at all. They call the same functions through test/helpers/legacy-wire.ts, which serves what rc.9 produced for each node from a recorded file. Parity is still checked against the JS implementation, just not by running it: a node with no recorded vector throws rather than passing quietly, and the header says how to re-record. Attribute order is part of the wire format, so the vector key preserves key order. Sorting it collapsed the two nodes in the order-sensitivity test onto one entry, which passed for the wrong reason until the replay run caught it. crypto-parity now compares against node's own crypto: MD5 and HKDF are standard, so the reference is the algorithm, not another implementation. appstate-parity and noise-session still need the dependency, because LT_HASH_ANTI_TAMPERING and makeNoiseHandler are client logic rather than primitives, and porting 1,600 lines of them is its own change. Also widen signalStorage's return type: the bridge's SignalStorage went from twelve members to two, and this file still calls the rest itself. --- packages/baileys/src/Signal/libsignal.ts | 10 + .../fixtures/legacy-session-rc9.json | 2271 +---------------- .../test/crypto-parity.test.ts | 26 +- .../test/handshake-parity.test.ts | 2 +- .../test/helpers/legacy-wire-vectors.json | 1 + .../test/helpers/legacy-wire.ts | 88 + .../whatsapp-rust-bridge/test/parity.test.ts | 2 +- .../test/server-response-parity.test.ts | 2 +- 8 files changed, 124 insertions(+), 2278 deletions(-) create mode 100644 packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json create mode 100644 packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts diff --git a/packages/baileys/src/Signal/libsignal.ts b/packages/baileys/src/Signal/libsignal.ts index 9b1baf55d20..b9a836a316a 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -847,8 +847,18 @@ function signalStorage( */ pinnedResolver?: (id: string) => Promise ): SignalStorage & { + // Members the bridge no longer asks for, but this file still calls: the + // group path is all the adapter needs from us now. + loadSession(id: string): Promise + storeSession(id: string, session: SessionRecord): Promise loadIdentityKey(id: string): Promise saveIdentity(id: string, identityKey: Uint8Array): Promise + isTrustedIdentity(): boolean + loadPreKey(id: number): Promise<{ pubKey: Uint8Array; privKey: Uint8Array } | null> + removePreKey(id: number): unknown + loadSignedPreKey(id: number): Promise + getOurRegistrationId(): number + getOurIdentity(): { privKey: Uint8Array; pubKey: Uint8Array } } { const resolveLIDSignalAddress = pinnedResolver ?? ((id: string) => resolveSignalAddressId(id, lidMapping)) diff --git a/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json b/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json index 30fae004dea..4fe697c8402 100644 --- a/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json +++ b/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json @@ -1,2270 +1 @@ -{ - "note": "Generated by baileys@7.0.0-rc.9 (JS libsignal, no Rust). Do not regenerate casually.", - "baileysVersion": "7.0.0-rc.9", - "jids": { - "aliceJid": "5511900000001@s.whatsapp.net", - "bobJid": "5511900000002@s.whatsapp.net", - "groupJid": "120363000000000001@g.us" - }, - "alice": { - "creds": { - "noiseKey": { - "private": { - "type": "Buffer", - "data": [ - 144, - 7, - 127, - 75, - 190, - 64, - 202, - 100, - 32, - 9, - 16, - 156, - 206, - 72, - 243, - 63, - 247, - 107, - 189, - 204, - 120, - 143, - 252, - 199, - 204, - 17, - 59, - 189, - 97, - 161, - 67, - 81 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 176, - 67, - 232, - 116, - 101, - 209, - 220, - 124, - 161, - 14, - 225, - 79, - 225, - 249, - 32, - 229, - 85, - 202, - 237, - 74, - 27, - 23, - 88, - 93, - 56, - 141, - 83, - 100, - 143, - 239, - 77, - 40 - ] - } - }, - "pairingEphemeralKeyPair": { - "private": { - "type": "Buffer", - "data": [ - 136, - 205, - 70, - 26, - 187, - 140, - 9, - 82, - 67, - 116, - 228, - 196, - 212, - 35, - 55, - 213, - 72, - 69, - 129, - 108, - 190, - 163, - 192, - 195, - 248, - 180, - 35, - 68, - 187, - 106, - 103, - 93 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 229, - 94, - 169, - 197, - 24, - 146, - 4, - 193, - 246, - 212, - 126, - 245, - 39, - 36, - 186, - 162, - 196, - 57, - 159, - 170, - 153, - 235, - 132, - 186, - 186, - 52, - 188, - 106, - 232, - 156, - 152, - 15 - ] - } - }, - "signedIdentityKey": { - "private": { - "type": "Buffer", - "data": [ - 96, - 195, - 120, - 100, - 204, - 254, - 133, - 133, - 244, - 18, - 235, - 0, - 99, - 136, - 88, - 143, - 66, - 108, - 57, - 195, - 13, - 120, - 45, - 230, - 23, - 207, - 172, - 14, - 254, - 23, - 47, - 100 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 163, - 65, - 97, - 229, - 211, - 186, - 54, - 214, - 80, - 129, - 14, - 30, - 69, - 125, - 49, - 222, - 49, - 250, - 50, - 163, - 201, - 7, - 199, - 93, - 47, - 53, - 232, - 51, - 237, - 75, - 168, - 11 - ] - } - }, - "signedPreKey": { - "keyPair": { - "private": { - "type": "Buffer", - "data": [ - 64, - 136, - 234, - 249, - 78, - 64, - 44, - 223, - 200, - 90, - 71, - 145, - 28, - 169, - 51, - 124, - 108, - 8, - 185, - 203, - 129, - 205, - 46, - 208, - 83, - 177, - 140, - 159, - 136, - 53, - 9, - 103 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 163, - 44, - 252, - 229, - 128, - 1, - 22, - 169, - 98, - 147, - 148, - 127, - 242, - 108, - 60, - 73, - 171, - 122, - 118, - 165, - 1, - 12, - 204, - 160, - 38, - 116, - 241, - 221, - 190, - 168, - 82, - 51 - ] - } - }, - "signature": { - "type": "Buffer", - "data": [ - 12, - 64, - 234, - 101, - 192, - 27, - 59, - 140, - 12, - 99, - 220, - 32, - 5, - 190, - 30, - 127, - 213, - 68, - 70, - 0, - 10, - 178, - 36, - 128, - 51, - 232, - 154, - 181, - 69, - 202, - 110, - 223, - 165, - 143, - 92, - 26, - 88, - 45, - 60, - 0, - 250, - 182, - 156, - 16, - 29, - 18, - 67, - 102, - 0, - 160, - 142, - 84, - 26, - 232, - 35, - 46, - 199, - 204, - 252, - 170, - 126, - 157, - 222, - 2 - ] - }, - "keyId": 1 - }, - "registrationId": 28, - "advSecretKey": "lxoNl9OSvm7KXkfl2h0x7a/rHBW+0TUbBp/AKoY8mwg=", - "processedHistoryMessages": [], - "nextPreKeyId": 1, - "firstUnuploadedPreKeyId": 1, - "accountSyncCounter": 0, - "accountSettings": { - "unarchiveChats": false - }, - "registered": false - }, - "store": { - "session": { - "5511900000002.0": { - "_sessions": { - "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A": { - "registrationId": 100, - "currentRatchet": { - "ephemeralKeyPair": { - "pubKey": "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w", - "privKey": "2IHYeE9DDwVhbQm9lIPZEbHQZIgwfI/m2JVTIdq9LGA=" - }, - "lastRemoteEphemeralKey": "BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu", - "previousCounter": 0, - "rootKey": "DSpJ7D6WsqblAPci/vmTf1YC4A6bAFM4Z2P+CbW5otw=" - }, - "indexInfo": { - "baseKey": "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A", - "baseKeyType": 1, - "closed": -1, - "used": 1785888560561, - "created": 1785888560558, - "remoteIdentityKey": "BQvCrnGGKEpyRfCo9iIoh0LzUHb9PuIaSNAyLa95CEBY" - }, - "_chains": { - "BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu": { - "chainKey": { - "counter": 0, - "key": "rSmnP46RcJicySjrqHjKhr35rtCfWB+1nHrEBXBBLZ0=" - }, - "chainType": 2, - "messageKeys": {} - }, - "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w": { - "chainKey": { - "counter": 2, - "key": "JrE5qGwQQgh8PRvi2dHlEh3aS12mfqA7zLXU9yT1xHA=" - }, - "chainType": 1, - "messageKeys": {} - } - } - } - }, - "version": "v1" - } - }, - "sender-key": { - "120363000000000001@g.us::5511900000001::0": { - "type": "Buffer", - "data": [ - 91, - 123, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 75, - 101, - 121, - 73, - 100, - 34, - 58, - 50, - 49, - 49, - 48, - 54, - 52, - 50, - 53, - 50, - 52, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 67, - 104, - 97, - 105, - 110, - 75, - 101, - 121, - 34, - 58, - 123, - 34, - 105, - 116, - 101, - 114, - 97, - 116, - 105, - 111, - 110, - 34, - 58, - 53, - 44, - 34, - 115, - 101, - 101, - 100, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 54, - 48, - 44, - 51, - 51, - 44, - 49, - 56, - 57, - 44, - 49, - 57, - 52, - 44, - 49, - 53, - 50, - 44, - 56, - 49, - 44, - 49, - 57, - 44, - 49, - 51, - 52, - 44, - 49, - 49, - 57, - 44, - 49, - 51, - 49, - 44, - 49, - 57, - 44, - 57, - 48, - 44, - 57, - 54, - 44, - 49, - 57, - 52, - 44, - 50, - 51, - 51, - 44, - 49, - 48, - 44, - 57, - 56, - 44, - 49, - 49, - 56, - 44, - 54, - 44, - 49, - 48, - 57, - 44, - 57, - 57, - 44, - 49, - 50, - 55, - 44, - 51, - 57, - 44, - 49, - 57, - 44, - 53, - 54, - 44, - 56, - 50, - 44, - 49, - 51, - 55, - 44, - 49, - 53, - 52, - 44, - 53, - 55, - 44, - 54, - 44, - 51, - 44, - 49, - 48, - 56, - 93, - 125, - 125, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 83, - 105, - 103, - 110, - 105, - 110, - 103, - 75, - 101, - 121, - 34, - 58, - 123, - 34, - 112, - 117, - 98, - 108, - 105, - 99, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 53, - 44, - 50, - 49, - 48, - 44, - 53, - 44, - 49, - 52, - 52, - 44, - 49, - 49, - 48, - 44, - 50, - 53, - 44, - 49, - 48, - 56, - 44, - 49, - 51, - 44, - 49, - 57, - 56, - 44, - 50, - 52, - 57, - 44, - 50, - 49, - 48, - 44, - 49, - 55, - 52, - 44, - 50, - 50, - 50, - 44, - 48, - 44, - 50, - 52, - 55, - 44, - 51, - 49, - 44, - 49, - 55, - 49, - 44, - 49, - 54, - 57, - 44, - 49, - 56, - 55, - 44, - 49, - 50, - 56, - 44, - 49, - 53, - 49, - 44, - 49, - 57, - 50, - 44, - 57, - 50, - 44, - 49, - 51, - 54, - 44, - 50, - 48, - 49, - 44, - 52, - 53, - 44, - 49, - 55, - 50, - 44, - 54, - 49, - 44, - 49, - 52, - 52, - 44, - 50, - 49, - 54, - 44, - 53, - 54, - 44, - 50, - 53, - 44, - 49, - 51, - 93, - 125, - 44, - 34, - 112, - 114, - 105, - 118, - 97, - 116, - 101, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 51, - 50, - 44, - 49, - 49, - 57, - 44, - 52, - 52, - 44, - 54, - 49, - 44, - 49, - 55, - 50, - 44, - 50, - 53, - 51, - 44, - 50, - 52, - 49, - 44, - 53, - 44, - 49, - 50, - 51, - 44, - 57, - 55, - 44, - 54, - 57, - 44, - 49, - 51, - 57, - 44, - 50, - 48, - 54, - 44, - 50, - 48, - 50, - 44, - 49, - 48, - 55, - 44, - 50, - 49, - 53, - 44, - 56, - 55, - 44, - 55, - 44, - 50, - 51, - 44, - 49, - 56, - 55, - 44, - 49, - 49, - 48, - 44, - 56, - 48, - 44, - 50, - 48, - 53, - 44, - 55, - 52, - 44, - 50, - 50, - 44, - 50, - 53, - 53, - 44, - 50, - 52, - 53, - 44, - 50, - 49, - 49, - 44, - 53, - 48, - 44, - 51, - 44, - 50, - 54, - 44, - 55, - 50, - 93, - 125, - 125, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 77, - 101, - 115, - 115, - 97, - 103, - 101, - 75, - 101, - 121, - 115, - 34, - 58, - 91, - 123, - 34, - 105, - 116, - 101, - 114, - 97, - 116, - 105, - 111, - 110, - 34, - 58, - 49, - 44, - 34, - 115, - 101, - 101, - 100, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 55, - 56, - 44, - 53, - 52, - 44, - 55, - 48, - 44, - 49, - 54, - 53, - 44, - 55, - 48, - 44, - 54, - 49, - 44, - 56, - 52, - 44, - 49, - 48, - 48, - 44, - 49, - 51, - 54, - 44, - 57, - 56, - 44, - 50, - 49, - 44, - 56, - 53, - 44, - 49, - 48, - 53, - 44, - 57, - 56, - 44, - 49, - 48, - 51, - 44, - 49, - 53, - 57, - 44, - 50, - 48, - 51, - 44, - 49, - 52, - 56, - 44, - 49, - 54, - 52, - 44, - 49, - 51, - 56, - 44, - 49, - 52, - 57, - 44, - 49, - 53, - 49, - 44, - 49, - 54, - 51, - 44, - 50, - 51, - 53, - 44, - 55, - 51, - 44, - 49, - 56, - 44, - 56, - 54, - 44, - 54, - 54, - 44, - 50, - 48, - 51, - 44, - 49, - 54, - 54, - 44, - 49, - 54, - 44, - 52, - 53, - 93, - 125, - 125, - 44, - 123, - 34, - 105, - 116, - 101, - 114, - 97, - 116, - 105, - 111, - 110, - 34, - 58, - 51, - 44, - 34, - 115, - 101, - 101, - 100, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 49, - 53, - 55, - 44, - 55, - 50, - 44, - 49, - 55, - 51, - 44, - 57, - 54, - 44, - 53, - 51, - 44, - 50, - 50, - 55, - 44, - 49, - 50, - 51, - 44, - 49, - 55, - 44, - 56, - 53, - 44, - 50, - 53, - 48, - 44, - 55, - 51, - 44, - 49, - 50, - 53, - 44, - 56, - 52, - 44, - 50, - 52, - 53, - 44, - 54, - 53, - 44, - 49, - 54, - 49, - 44, - 50, - 51, - 49, - 44, - 50, - 57, - 44, - 52, - 48, - 44, - 57, - 44, - 50, - 52, - 55, - 44, - 49, - 56, - 44, - 49, - 51, - 57, - 44, - 49, - 54, - 50, - 44, - 56, - 51, - 44, - 49, - 57, - 55, - 44, - 49, - 57, - 55, - 44, - 53, - 57, - 44, - 49, - 56, - 54, - 44, - 50, - 50, - 49, - 44, - 50, - 53, - 48, - 44, - 57, - 48, - 93, - 125, - 125, - 93, - 125, - 93 - ] - } - } - } - }, - "bob": { - "creds": { - "noiseKey": { - "private": { - "type": "Buffer", - "data": [ - 216, - 60, - 178, - 186, - 185, - 3, - 97, - 53, - 122, - 237, - 205, - 239, - 24, - 39, - 137, - 83, - 76, - 115, - 96, - 49, - 170, - 226, - 134, - 147, - 211, - 58, - 144, - 62, - 34, - 43, - 165, - 124 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 226, - 55, - 2, - 122, - 8, - 214, - 23, - 0, - 240, - 190, - 205, - 209, - 71, - 59, - 147, - 176, - 11, - 72, - 132, - 170, - 69, - 11, - 27, - 226, - 204, - 191, - 238, - 23, - 1, - 98, - 38, - 109 - ] - } - }, - "pairingEphemeralKeyPair": { - "private": { - "type": "Buffer", - "data": [ - 48, - 48, - 141, - 222, - 99, - 84, - 211, - 254, - 55, - 8, - 139, - 77, - 195, - 44, - 155, - 209, - 141, - 178, - 97, - 53, - 127, - 239, - 216, - 77, - 203, - 182, - 20, - 217, - 51, - 209, - 13, - 124 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 250, - 202, - 27, - 62, - 43, - 63, - 201, - 41, - 121, - 52, - 138, - 157, - 155, - 250, - 235, - 208, - 84, - 233, - 180, - 139, - 221, - 90, - 127, - 96, - 21, - 4, - 20, - 125, - 186, - 20, - 22, - 28 - ] - } - }, - "signedIdentityKey": { - "private": { - "type": "Buffer", - "data": [ - 56, - 138, - 44, - 56, - 239, - 76, - 32, - 179, - 253, - 69, - 97, - 234, - 171, - 31, - 202, - 166, - 250, - 136, - 158, - 16, - 47, - 246, - 199, - 101, - 98, - 229, - 170, - 240, - 44, - 84, - 95, - 109 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 11, - 194, - 174, - 113, - 134, - 40, - 74, - 114, - 69, - 240, - 168, - 246, - 34, - 40, - 135, - 66, - 243, - 80, - 118, - 253, - 62, - 226, - 26, - 72, - 208, - 50, - 45, - 175, - 121, - 8, - 64, - 88 - ] - } - }, - "signedPreKey": { - "keyPair": { - "private": { - "type": "Buffer", - "data": [ - 32, - 196, - 57, - 135, - 21, - 199, - 66, - 180, - 22, - 65, - 95, - 205, - 225, - 170, - 215, - 58, - 17, - 135, - 175, - 83, - 85, - 200, - 181, - 118, - 108, - 251, - 183, - 173, - 158, - 160, - 85, - 88 - ] - }, - "public": { - "type": "Buffer", - "data": [ - 156, - 185, - 74, - 160, - 170, - 191, - 220, - 32, - 137, - 126, - 69, - 198, - 199, - 4, - 116, - 183, - 146, - 169, - 188, - 85, - 225, - 179, - 243, - 172, - 210, - 217, - 38, - 245, - 32, - 143, - 87, - 38 - ] - } - }, - "signature": { - "type": "Buffer", - "data": [ - 204, - 241, - 93, - 140, - 118, - 102, - 248, - 212, - 234, - 222, - 132, - 234, - 186, - 149, - 64, - 223, - 114, - 23, - 2, - 42, - 144, - 76, - 206, - 111, - 182, - 12, - 166, - 142, - 44, - 181, - 229, - 106, - 36, - 78, - 117, - 144, - 143, - 13, - 162, - 174, - 101, - 252, - 67, - 197, - 119, - 219, - 197, - 17, - 223, - 172, - 213, - 104, - 44, - 123, - 237, - 58, - 38, - 228, - 250, - 90, - 202, - 218, - 230, - 1 - ] - }, - "keyId": 1 - }, - "registrationId": 100, - "advSecretKey": "TucKh1/PV+F1KJDBv5U3zrNKwNtfIQkvrE9ijcEzPwA=", - "processedHistoryMessages": [], - "nextPreKeyId": 1, - "firstUnuploadedPreKeyId": 1, - "accountSyncCounter": 0, - "accountSettings": { - "unarchiveChats": false - }, - "registered": false - }, - "store": { - "pre-key": {}, - "session": { - "5511900000001.0": { - "_sessions": { - "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A": { - "registrationId": 28, - "currentRatchet": { - "ephemeralKeyPair": { - "pubKey": "Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y", - "privKey": "QNMOO65JPpWJ3lm+G6u4PWRjUgmXBYXVKhYt7qrd7Gs=" - }, - "lastRemoteEphemeralKey": "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w", - "previousCounter": 0, - "rootKey": "SEny8Z0Ae/SsRV8Z2YBHID8d21Qcr75QwsbqkNkLawo=" - }, - "indexInfo": { - "baseKey": "BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A", - "baseKeyType": 2, - "closed": -1, - "used": 1785888560562, - "created": 1785888560560, - "remoteIdentityKey": "BaNBYeXTujbWUIEOHkV9Md4x+jKjyQfHXS816DPtS6gL" - }, - "_chains": { - "BWZkNHQDFNGwWjnHJZqCH5BVeSb0IRX9uBgiX8tZoo4G": { - "chainKey": { - "counter": 0 - }, - "chainType": 2, - "messageKeys": {} - }, - "BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w": { - "chainKey": { - "counter": 0, - "key": "kThx60YHFhmq7wH/x1/ZN+842vfj//enBn1NnRWbxdc=" - }, - "chainType": 2, - "messageKeys": {} - }, - "Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y": { - "chainKey": { - "counter": -1, - "key": "2RFNlcpQPYZniWXeSTzl6EZVXEqvN5R9/Zwx3ZXMXJY=" - }, - "chainType": 1, - "messageKeys": {} - } - } - } - }, - "version": "v1" - } - }, - "sender-key": { - "120363000000000001@g.us::5511900000001::0": { - "type": "Buffer", - "data": [ - 91, - 123, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 75, - 101, - 121, - 73, - 100, - 34, - 58, - 50, - 49, - 49, - 48, - 54, - 52, - 50, - 53, - 50, - 52, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 67, - 104, - 97, - 105, - 110, - 75, - 101, - 121, - 34, - 58, - 123, - 34, - 105, - 116, - 101, - 114, - 97, - 116, - 105, - 111, - 110, - 34, - 58, - 49, - 44, - 34, - 115, - 101, - 101, - 100, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 55, - 50, - 44, - 50, - 48, - 51, - 44, - 53, - 55, - 44, - 49, - 57, - 49, - 44, - 50, - 53, - 51, - 44, - 54, - 48, - 44, - 50, - 48, - 49, - 44, - 50, - 51, - 49, - 44, - 49, - 57, - 57, - 44, - 50, - 52, - 57, - 44, - 49, - 48, - 56, - 44, - 49, - 51, - 50, - 44, - 50, - 49, - 51, - 44, - 49, - 54, - 57, - 44, - 51, - 44, - 57, - 48, - 44, - 53, - 56, - 44, - 49, - 54, - 50, - 44, - 57, - 56, - 44, - 50, - 52, - 54, - 44, - 49, - 54, - 52, - 44, - 53, - 48, - 44, - 49, - 49, - 56, - 44, - 49, - 51, - 49, - 44, - 50, - 52, - 57, - 44, - 50, - 48, - 53, - 44, - 50, - 49, - 54, - 44, - 49, - 49, - 57, - 44, - 49, - 51, - 49, - 44, - 49, - 55, - 49, - 44, - 49, - 51, - 48, - 44, - 49, - 54, - 51, - 93, - 125, - 125, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 83, - 105, - 103, - 110, - 105, - 110, - 103, - 75, - 101, - 121, - 34, - 58, - 123, - 34, - 112, - 117, - 98, - 108, - 105, - 99, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 53, - 44, - 50, - 49, - 48, - 44, - 53, - 44, - 49, - 52, - 52, - 44, - 49, - 49, - 48, - 44, - 50, - 53, - 44, - 49, - 48, - 56, - 44, - 49, - 51, - 44, - 49, - 57, - 56, - 44, - 50, - 52, - 57, - 44, - 50, - 49, - 48, - 44, - 49, - 55, - 52, - 44, - 50, - 50, - 50, - 44, - 48, - 44, - 50, - 52, - 55, - 44, - 51, - 49, - 44, - 49, - 55, - 49, - 44, - 49, - 54, - 57, - 44, - 49, - 56, - 55, - 44, - 49, - 50, - 56, - 44, - 49, - 53, - 49, - 44, - 49, - 57, - 50, - 44, - 57, - 50, - 44, - 49, - 51, - 54, - 44, - 50, - 48, - 49, - 44, - 52, - 53, - 44, - 49, - 55, - 50, - 44, - 54, - 49, - 44, - 49, - 52, - 52, - 44, - 50, - 49, - 54, - 44, - 53, - 54, - 44, - 50, - 53, - 44, - 49, - 51, - 93, - 125, - 44, - 34, - 112, - 114, - 105, - 118, - 97, - 116, - 101, - 34, - 58, - 123, - 34, - 116, - 121, - 112, - 101, - 34, - 58, - 34, - 66, - 117, - 102, - 102, - 101, - 114, - 34, - 44, - 34, - 100, - 97, - 116, - 97, - 34, - 58, - 91, - 93, - 125, - 125, - 44, - 34, - 115, - 101, - 110, - 100, - 101, - 114, - 77, - 101, - 115, - 115, - 97, - 103, - 101, - 75, - 101, - 121, - 115, - 34, - 58, - 91, - 93, - 125, - 93 - ] - } - } - } - }, - "dmTranscript": [ - { - "dir": "a2b", - "type": "pkmsg", - "ct": "MwgBEiEFFhwYwPexZfP4jMJdguPxDtdbk3K8Q3tarbjaIW9OPgAaIQWjQWHl07o21lCBDh5FfTHeMfoyo8kHx10vNegz7UuoCyJSMwohBWZkNHQDFNGwWjnHJZqCH5BVeSb0IRX9uBgiX8tZoo4GEAAYACIgnmVw9pxcyxH+h8rkNWyvSqxTzpJ2fpsXsHUvTVYA5yqRA41mUQeLZygcMAE=", - "pt": "msg-1 from alice" - }, - { - "dir": "b2a", - "type": "msg", - "ct": "MwohBZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhuEAAYACIQOz5hRleLiwIYzZmWgcFXrIDEDcBbrmoe", - "pt": "msg-2 from bob" - }, - { - "dir": "a2b", - "type": "msg", - "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAAYACIg+qpLVR8j70REQK1xwWG6boVJ6CFdSXJ3DRavbEfQ79lJ8LZtKUBY4g==", - "pt": "msg-3 from alice" - } - ], - "pending": [ - { - "type": "msg", - "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAEYACIQ962b1VLGDctn2YBY2kj0ic7Ai+jp8au0", - "pt": "pending-1" - }, - { - "type": "msg", - "ct": "MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAIYACIQUGHRBuJrm5+n1xBvCfSYVqkNpxyOLveU", - "pt": "pending-2" - } - ], - "groupTranscript": [ - { - "ct": "MwjcsrfuBxAAGhBRrAkVr7SLgKCFdn8LHbJ9c8BJL9OfydIfLtISginFN60Gy6GmlSusVyNbu++IAamVcb1ir0FP9SOebpUo1ezV4xPPngT+rxeOdiO/4oImBQ==", - "pt": "group-1" - } - ], - "pendingGroup": [ - { - "ct": "MwjcsrfuBxACGhAvmgj8R+f17g81uFSMq1r/4tYe2VksPseAuEHiE2UC7CrClG8/SIKAK0l0gRF1hdvruRJdmR5GkZ8dsSS1r907wznMKPNYke8EcYNF21l2BQ==", - "pt": "group-pending-1" - }, - { - "ct": "MwjcsrfuBxAEGhCwWg6AUukRZ2V86u+JsrRnXWii6MCjILBkUUt0OBdhvxqK13Qw68wGS8CJ50GHNc6n0yk+HFhc2mnanLMwjStGqUrheK969OuZLDcEKTXOCQ==", - "pt": "group-pending-2" - } - ] -} \ No newline at end of file +{"jids":{"aliceJid":"5511900000001@s.whatsapp.net","bobJid":"5511900000002@s.whatsapp.net","groupJid":"120363000000000001@g.us"},"alice":{"creds":{"noiseKey":{"private":{"type":"Buffer","data":[144,7,127,75,190,64,202,100,32,9,16,156,206,72,243,63,247,107,189,204,120,143,252,199,204,17,59,189,97,161,67,81]},"public":{"type":"Buffer","data":[176,67,232,116,101,209,220,124,161,14,225,79,225,249,32,229,85,202,237,74,27,23,88,93,56,141,83,100,143,239,77,40]}},"pairingEphemeralKeyPair":{"private":{"type":"Buffer","data":[136,205,70,26,187,140,9,82,67,116,228,196,212,35,55,213,72,69,129,108,190,163,192,195,248,180,35,68,187,106,103,93]},"public":{"type":"Buffer","data":[229,94,169,197,24,146,4,193,246,212,126,245,39,36,186,162,196,57,159,170,153,235,132,186,186,52,188,106,232,156,152,15]}},"signedIdentityKey":{"private":{"type":"Buffer","data":[96,195,120,100,204,254,133,133,244,18,235,0,99,136,88,143,66,108,57,195,13,120,45,230,23,207,172,14,254,23,47,100]},"public":{"type":"Buffer","data":[163,65,97,229,211,186,54,214,80,129,14,30,69,125,49,222,49,250,50,163,201,7,199,93,47,53,232,51,237,75,168,11]}},"signedPreKey":{"keyPair":{"private":{"type":"Buffer","data":[64,136,234,249,78,64,44,223,200,90,71,145,28,169,51,124,108,8,185,203,129,205,46,208,83,177,140,159,136,53,9,103]},"public":{"type":"Buffer","data":[163,44,252,229,128,1,22,169,98,147,148,127,242,108,60,73,171,122,118,165,1,12,204,160,38,116,241,221,190,168,82,51]}},"signature":{"type":"Buffer","data":[12,64,234,101,192,27,59,140,12,99,220,32,5,190,30,127,213,68,70,0,10,178,36,128,51,232,154,181,69,202,110,223,165,143,92,26,88,45,60,0,250,182,156,16,29,18,67,102,0,160,142,84,26,232,35,46,199,204,252,170,126,157,222,2]},"keyId":1},"registrationId":28,"advSecretKey":"lxoNl9OSvm7KXkfl2h0x7a/rHBW+0TUbBp/AKoY8mwg=","processedHistoryMessages":[],"nextPreKeyId":1,"firstUnuploadedPreKeyId":1,"accountSyncCounter":0,"accountSettings":{"unarchiveChats":false},"registered":false},"store":{"session":{"5511900000002.0":{"_sessions":{"BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A":{"registrationId":100,"currentRatchet":{"ephemeralKeyPair":{"pubKey":"BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w","privKey":"2IHYeE9DDwVhbQm9lIPZEbHQZIgwfI/m2JVTIdq9LGA="},"lastRemoteEphemeralKey":"BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu","previousCounter":0,"rootKey":"DSpJ7D6WsqblAPci/vmTf1YC4A6bAFM4Z2P+CbW5otw="},"indexInfo":{"baseKey":"BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A","baseKeyType":1,"closed":-1,"used":1785888560561,"created":1785888560558,"remoteIdentityKey":"BQvCrnGGKEpyRfCo9iIoh0LzUHb9PuIaSNAyLa95CEBY"},"_chains":{"BZ/l/21WYI4tuOvHGlVd3uUL+hMbtjCdAH0+5H4kUBhu":{"chainKey":{"counter":0,"key":"rSmnP46RcJicySjrqHjKhr35rtCfWB+1nHrEBXBBLZ0="},"chainType":2,"messageKeys":{}},"BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w":{"chainKey":{"counter":2,"key":"JrE5qGwQQgh8PRvi2dHlEh3aS12mfqA7zLXU9yT1xHA="},"chainType":1,"messageKeys":{}}}}},"version":"v1"}},"sender-key":{"120363000000000001@g.us::5511900000001::0":{"type":"Buffer","data":[91,123,34,115,101,110,100,101,114,75,101,121,73,100,34,58,50,49,49,48,54,52,50,53,50,52,44,34,115,101,110,100,101,114,67,104,97,105,110,75,101,121,34,58,123,34,105,116,101,114,97,116,105,111,110,34,58,53,44,34,115,101,101,100,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,54,48,44,51,51,44,49,56,57,44,49,57,52,44,49,53,50,44,56,49,44,49,57,44,49,51,52,44,49,49,57,44,49,51,49,44,49,57,44,57,48,44,57,54,44,49,57,52,44,50,51,51,44,49,48,44,57,56,44,49,49,56,44,54,44,49,48,57,44,57,57,44,49,50,55,44,51,57,44,49,57,44,53,54,44,56,50,44,49,51,55,44,49,53,52,44,53,55,44,54,44,51,44,49,48,56,93,125,125,44,34,115,101,110,100,101,114,83,105,103,110,105,110,103,75,101,121,34,58,123,34,112,117,98,108,105,99,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,53,44,50,49,48,44,53,44,49,52,52,44,49,49,48,44,50,53,44,49,48,56,44,49,51,44,49,57,56,44,50,52,57,44,50,49,48,44,49,55,52,44,50,50,50,44,48,44,50,52,55,44,51,49,44,49,55,49,44,49,54,57,44,49,56,55,44,49,50,56,44,49,53,49,44,49,57,50,44,57,50,44,49,51,54,44,50,48,49,44,52,53,44,49,55,50,44,54,49,44,49,52,52,44,50,49,54,44,53,54,44,50,53,44,49,51,93,125,44,34,112,114,105,118,97,116,101,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,51,50,44,49,49,57,44,52,52,44,54,49,44,49,55,50,44,50,53,51,44,50,52,49,44,53,44,49,50,51,44,57,55,44,54,57,44,49,51,57,44,50,48,54,44,50,48,50,44,49,48,55,44,50,49,53,44,56,55,44,55,44,50,51,44,49,56,55,44,49,49,48,44,56,48,44,50,48,53,44,55,52,44,50,50,44,50,53,53,44,50,52,53,44,50,49,49,44,53,48,44,51,44,50,54,44,55,50,93,125,125,44,34,115,101,110,100,101,114,77,101,115,115,97,103,101,75,101,121,115,34,58,91,123,34,105,116,101,114,97,116,105,111,110,34,58,49,44,34,115,101,101,100,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,55,56,44,53,52,44,55,48,44,49,54,53,44,55,48,44,54,49,44,56,52,44,49,48,48,44,49,51,54,44,57,56,44,50,49,44,56,53,44,49,48,53,44,57,56,44,49,48,51,44,49,53,57,44,50,48,51,44,49,52,56,44,49,54,52,44,49,51,56,44,49,52,57,44,49,53,49,44,49,54,51,44,50,51,53,44,55,51,44,49,56,44,56,54,44,54,54,44,50,48,51,44,49,54,54,44,49,54,44,52,53,93,125,125,44,123,34,105,116,101,114,97,116,105,111,110,34,58,51,44,34,115,101,101,100,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,49,53,55,44,55,50,44,49,55,51,44,57,54,44,53,51,44,50,50,55,44,49,50,51,44,49,55,44,56,53,44,50,53,48,44,55,51,44,49,50,53,44,56,52,44,50,52,53,44,54,53,44,49,54,49,44,50,51,49,44,50,57,44,52,48,44,57,44,50,52,55,44,49,56,44,49,51,57,44,49,54,50,44,56,51,44,49,57,55,44,49,57,55,44,53,57,44,49,56,54,44,50,50,49,44,50,53,48,44,57,48,93,125,125,93,125,93]}}}},"bob":{"creds":{"noiseKey":{"private":{"type":"Buffer","data":[216,60,178,186,185,3,97,53,122,237,205,239,24,39,137,83,76,115,96,49,170,226,134,147,211,58,144,62,34,43,165,124]},"public":{"type":"Buffer","data":[226,55,2,122,8,214,23,0,240,190,205,209,71,59,147,176,11,72,132,170,69,11,27,226,204,191,238,23,1,98,38,109]}},"pairingEphemeralKeyPair":{"private":{"type":"Buffer","data":[48,48,141,222,99,84,211,254,55,8,139,77,195,44,155,209,141,178,97,53,127,239,216,77,203,182,20,217,51,209,13,124]},"public":{"type":"Buffer","data":[250,202,27,62,43,63,201,41,121,52,138,157,155,250,235,208,84,233,180,139,221,90,127,96,21,4,20,125,186,20,22,28]}},"signedIdentityKey":{"private":{"type":"Buffer","data":[56,138,44,56,239,76,32,179,253,69,97,234,171,31,202,166,250,136,158,16,47,246,199,101,98,229,170,240,44,84,95,109]},"public":{"type":"Buffer","data":[11,194,174,113,134,40,74,114,69,240,168,246,34,40,135,66,243,80,118,253,62,226,26,72,208,50,45,175,121,8,64,88]}},"signedPreKey":{"keyPair":{"private":{"type":"Buffer","data":[32,196,57,135,21,199,66,180,22,65,95,205,225,170,215,58,17,135,175,83,85,200,181,118,108,251,183,173,158,160,85,88]},"public":{"type":"Buffer","data":[156,185,74,160,170,191,220,32,137,126,69,198,199,4,116,183,146,169,188,85,225,179,243,172,210,217,38,245,32,143,87,38]}},"signature":{"type":"Buffer","data":[204,241,93,140,118,102,248,212,234,222,132,234,186,149,64,223,114,23,2,42,144,76,206,111,182,12,166,142,44,181,229,106,36,78,117,144,143,13,162,174,101,252,67,197,119,219,197,17,223,172,213,104,44,123,237,58,38,228,250,90,202,218,230,1]},"keyId":1},"registrationId":100,"advSecretKey":"TucKh1/PV+F1KJDBv5U3zrNKwNtfIQkvrE9ijcEzPwA=","processedHistoryMessages":[],"nextPreKeyId":1,"firstUnuploadedPreKeyId":1,"accountSyncCounter":0,"accountSettings":{"unarchiveChats":false},"registered":false},"store":{"pre-key":{},"session":{"5511900000001.0":{"_sessions":{"BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A":{"registrationId":28,"currentRatchet":{"ephemeralKeyPair":{"pubKey":"Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y","privKey":"QNMOO65JPpWJ3lm+G6u4PWRjUgmXBYXVKhYt7qrd7Gs="},"lastRemoteEphemeralKey":"BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w","previousCounter":0,"rootKey":"SEny8Z0Ae/SsRV8Z2YBHID8d21Qcr75QwsbqkNkLawo="},"indexInfo":{"baseKey":"BRYcGMD3sWXz+IzCXYLj8Q7XW5NyvEN7Wq242iFvTj4A","baseKeyType":2,"closed":-1,"used":1785888560562,"created":1785888560560,"remoteIdentityKey":"BaNBYeXTujbWUIEOHkV9Md4x+jKjyQfHXS816DPtS6gL"},"_chains":{"BWZkNHQDFNGwWjnHJZqCH5BVeSb0IRX9uBgiX8tZoo4G":{"chainKey":{"counter":0},"chainType":2,"messageKeys":{}},"BQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5w":{"chainKey":{"counter":0,"key":"kThx60YHFhmq7wH/x1/ZN+842vfj//enBn1NnRWbxdc="},"chainType":2,"messageKeys":{}},"Bcii2IxMbKsoMEceU7lN4g0jRedo8lihQGJNEYNHT65Y":{"chainKey":{"counter":-1,"key":"2RFNlcpQPYZniWXeSTzl6EZVXEqvN5R9/Zwx3ZXMXJY="},"chainType":1,"messageKeys":{}}}}},"version":"v1"}},"sender-key":{"120363000000000001@g.us::5511900000001::0":{"type":"Buffer","data":[91,123,34,115,101,110,100,101,114,75,101,121,73,100,34,58,50,49,49,48,54,52,50,53,50,52,44,34,115,101,110,100,101,114,67,104,97,105,110,75,101,121,34,58,123,34,105,116,101,114,97,116,105,111,110,34,58,49,44,34,115,101,101,100,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,55,50,44,50,48,51,44,53,55,44,49,57,49,44,50,53,51,44,54,48,44,50,48,49,44,50,51,49,44,49,57,57,44,50,52,57,44,49,48,56,44,49,51,50,44,50,49,51,44,49,54,57,44,51,44,57,48,44,53,56,44,49,54,50,44,57,56,44,50,52,54,44,49,54,52,44,53,48,44,49,49,56,44,49,51,49,44,50,52,57,44,50,48,53,44,50,49,54,44,49,49,57,44,49,51,49,44,49,55,49,44,49,51,48,44,49,54,51,93,125,125,44,34,115,101,110,100,101,114,83,105,103,110,105,110,103,75,101,121,34,58,123,34,112,117,98,108,105,99,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,53,44,50,49,48,44,53,44,49,52,52,44,49,49,48,44,50,53,44,49,48,56,44,49,51,44,49,57,56,44,50,52,57,44,50,49,48,44,49,55,52,44,50,50,50,44,48,44,50,52,55,44,51,49,44,49,55,49,44,49,54,57,44,49,56,55,44,49,50,56,44,49,53,49,44,49,57,50,44,57,50,44,49,51,54,44,50,48,49,44,52,53,44,49,55,50,44,54,49,44,49,52,52,44,50,49,54,44,53,54,44,50,53,44,49,51,93,125,44,34,112,114,105,118,97,116,101,34,58,123,34,116,121,112,101,34,58,34,66,117,102,102,101,114,34,44,34,100,97,116,97,34,58,91,93,125,125,44,34,115,101,110,100,101,114,77,101,115,115,97,103,101,75,101,121,115,34,58,91,93,125,93]}}}},"pending":[{"type":"msg","ct":"MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAEYACIQ962b1VLGDctn2YBY2kj0ic7Ai+jp8au0","pt":"pending-1"},{"type":"msg","ct":"MwohBQfEv6XVt+ln0lt+DlDOxFpU58edgHQ64CK7csYsnJ5wEAIYACIQUGHRBuJrm5+n1xBvCfSYVqkNpxyOLveU","pt":"pending-2"}],"pendingGroup":[{"ct":"MwjcsrfuBxACGhAvmgj8R+f17g81uFSMq1r/4tYe2VksPseAuEHiE2UC7CrClG8/SIKAK0l0gRF1hdvruRJdmR5GkZ8dsSS1r907wznMKPNYke8EcYNF21l2BQ==","pt":"group-pending-1"},{"ct":"MwjcsrfuBxAEGhCwWg6AUukRZ2V86u+JsrRnXWii6MCjILBkUUt0OBdhvxqK13Qw68wGS8CJ50GHNc6n0yk+HFhc2mnanLMwjStGqUrheK969OuZLDcEKTXOCQ==","pt":"group-pending-2"}]} \ No newline at end of file diff --git a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts index a26cd1d351d..9b44b7686f9 100644 --- a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts @@ -1,10 +1,26 @@ import { describe, it, expect } from "@jest/globals"; -import { randomBytes } from "crypto"; +import { createHash, hkdfSync, randomBytes } from "crypto"; import { md5, hkdf } from "../dist/index.js"; -import { - md5 as baileysMd5, - hkdf as baileysHkdf, -} from "baileys/lib/Utils/crypto.js"; + +// Node itself, not the JS client: MD5 and HKDF are standard, so the reference +// is the algorithm rather than another implementation of it. +const baileysMd5 = (data: Uint8Array) => + createHash("md5").update(data).digest(); + +const baileysHkdf = async ( + ikm: Uint8Array, + length: number, + { salt, info }: { salt?: Uint8Array; info?: string } = {}, +) => + Buffer.from( + hkdfSync( + "sha256", + ikm, + salt ?? Buffer.alloc(0), + info ? Buffer.from(info) : Buffer.alloc(0), + length, + ), + ); function hex(buffer: Uint8Array | Buffer): string { return Buffer.from(buffer).toString("hex"); diff --git a/packages/whatsapp-rust-bridge/test/handshake-parity.test.ts b/packages/whatsapp-rust-bridge/test/handshake-parity.test.ts index 689b66bbfb5..8f418675795 100644 --- a/packages/whatsapp-rust-bridge/test/handshake-parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/handshake-parity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "@jest/globals"; -import { encodeBinaryNode } from "baileys"; +import { encodeBinaryNode } from "./helpers/legacy-wire"; import { encodeNode, decodeNode, type BinaryNode } from "../dist/index.js"; function hex(buffer: Uint8Array): string { diff --git a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json new file mode 100644 index 00000000000..6a5f7c1f829 --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json @@ -0,0 +1 @@ +{"encoded":{"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"257131597\"}}":"APgHGRH6AAMEFAj/hSVxMVl/","{\"tag\":\"iq\",\"attrs\":{\"id\":\"3661.63898-1\",\"xmlns\":\"encrypt\",\"type\":\"get\",\"to\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"count\",\"attrs\":{}}]}":"APgKGQj/BjZhtjiYoRbLBCkR+gAD+AH4AUE=","{\"tag\":\"iq\",\"attrs\":{\"xmlns\":\"encrypt\",\"type\":\"set\",\"to\":\"@s.whatsapp.net\",\"id\":\"test-prekey-1\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}},{\"tag\":\"type\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[5]}},{\"tag\":\"identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[87,239,132,60,231,104,178,141,140,15,4,110,170,215,201,115,184,86,135,90,16,255,7,241,58,146,220,188,81,216,163,88]}},{\"tag\":\"list\",\"attrs\":{},\"content\":[{\"tag\":\"key\",\"attrs\":{},\"content\":[{\"tag\":\"id\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,1]}},{\"tag\":\"value\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[231,73,194,190,245,59,144,243,124,216,216,101,92,212,223,115,176,146,75,218,213,162,234,80,100,18,161,199,46,178,229,4]}}]}]}]}":"APgKGRbLBFoR+gADCPwNdGVzdC1wcmVrZXktMfgE+AKt/AQAAAD++AIE/AEF+AKc/CBX74Q852iyjYwPBG6q18lzuFaHWhD/B/E6kty8UdijWPgCcfgB+AKe+AL4Agj8AwAAAfgCbPwg50nCvvU7kPN82NhlXNTfc7CSS9rVoupQZBKhxy6y5QQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"2204545668\"},\"content\":[{\"tag\":\"pair-device-sign\",\"attrs\":{},\"content\":[{\"tag\":\"device-identity\",\"attrs\":{\"key-index\":\"19\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,34,64,231,116,71,48,212,66,147,169,148,223,4,5,170,170,217,243,47,180,75,82,214,34,85,101,183,215,193,254,105,26,99,151,89,101,95,29,50,178,32,143,182,227,139,160,172,199,250,144,135,202,61,77,132,218,60,16,160,194,193,109,81,198,49,3]}}]}]}":"APgIGRH6AAMEFAj/BSIEVFZo+AH4AvwQcGFpci1kZXZpY2Utc2lnbvgB+ATmUO2G/JgKEgizoJ/ZBhDD2sPJBhgTIAAoABpAlI91YIvr0nb7c4bLCJn5os6LksUgZ5PqbCLGxxSyipnKgHjo+a6oL28VQOp//04x9SqYQWbdNlPi7xvR5IzrCyJA53RHMNRCk6mU3wQFqqrZ8y+0S1LWIlVlt9fB/mkaY5dZZV8dMrIgj7bji6Csx/qQh8o9TYTaPBCgwsFtUcYxAw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"1422037390\"}}":"APgHGRH6AAMEFAj/BRQiA3OQ","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"21290.10000-1\"}}":"APgHGRH6AAMEKQj/hyEpCxAACh8=","{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}":"APgCJ/wGCAIIEggN","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}":"APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"dirty\",\"attrs\":{\"type\":\"account_sync\",\"timestamp\":\"1764814151\"}}]}":"APgE/AJpYgb6AAP4AfgF7QEE7G7smv8FF2SBQVE=","{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"}}":"APgDnXD/glFf","{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\"}}":"APgDOwz3AE3/BlWZhHJmYg==","{\"tag\":\"device\",\"attrs\":{\"jid\":\"236395184570386:77@lid\",\"lid\":\"236395184570386:77@lid\"}}":"APgFOwz3AU3/iCNjlRhFcDhvdvcBTf+II2OVGEVwOG8=","{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}":"APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test123\",\"xmlns\":\"encrypt\"}}":"APgJGRH6AAMEFAj8B3Rlc3QxMjMWyw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\"}}":"APgFGRH6AAMEFA==","{\"tag\":\"iq\",\"attrs\":{\"type\":\"result\",\"to\":\"@s.whatsapp.net\"}}":"APgFGQQUEfoAAw==","{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}":"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==","{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}":"APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}":"APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}":"APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}":"APgE/AJpYgb6AAP4AfgDEkEt","{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}":"APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=","{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}":"APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==","{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}":"APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=","{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}":"APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}":"APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}":"APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA","{\"tag\":\"iq\",\"attrs\":{\"to\":\"s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGREDBFoIVgA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgGExH6/wUSNFZ4kAMI/AVtc2ctMQA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}":"APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=","{\"tag\":\"message\",\"attrs\":{},\"content\":\"Hello World\"}":"APgCE/wLSGVsbG8gV29ybGQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGRH6AAMEWghWAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}":"APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA","{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}":"APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}":"APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}":"APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE="},"decoded":{"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}","APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}","APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==":"{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}","APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}","APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}","APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}","APgE/AJpYgb6AAP4AfgDEkEt":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}","APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}","APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==":"{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}","APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}","APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=":"{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}","APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}","APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}","APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}","APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}","APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}","APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}","APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}","APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE=":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}","APgEnXD/glFfAA==":"{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"},\"content\":[]}"}} \ No newline at end of file diff --git a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts new file mode 100644 index 00000000000..672b845578c --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts @@ -0,0 +1,88 @@ +/** + * What baileys@7.0.0-rc.9 produced for the nodes the parity suites use. + * + * Parity is still checked against the JS implementation, but from recorded + * vectors rather than a build-time dependency on it. A node with no vector + * throws instead of silently passing. + * + * To re-record after adding a case: `pnpm add -D baileys@7.0.0-rc.9`, run + * `RECORD_LEGACY_VECTORS=1 pnpm test parity`, commit the JSON, drop the dep. + */ +import { createRequire } from "node:module"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +type Node = { tag: string; attrs: Record; content?: unknown }; + +const here = dirname(fileURLToPath(import.meta.url)); +const store = resolve(here, "legacy-wire-vectors.json"); +const recording = Boolean(process.env.RECORD_LEGACY_VECTORS); + +const vectors: { encoded: Record; decoded: Record } = existsSync(store) + ? JSON.parse(readFileSync(store, "utf8")) + : { encoded: {}, decoded: {} }; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const legacy: any = recording ? createRequire(import.meta.url)("baileys") : undefined; +/** Merged on every write: jest may run these files in separate workers. */ +const persist = () => { + const onDisk = existsSync(store) + ? JSON.parse(readFileSync(store, "utf8")) + : { encoded: {}, decoded: {} }; + writeFileSync( + store, + JSON.stringify({ + encoded: { ...onDisk.encoded, ...vectors.encoded }, + decoded: { ...onDisk.decoded, ...vectors.decoded }, + }), + ); +}; + +/** Key order is significant: the wire format encodes attributes in order. */ +const canon = (value: unknown): string => + JSON.stringify(value, (_, v) => { + if (v instanceof Uint8Array || Buffer.isBuffer(v)) { + return { __b: Buffer.from(v as Uint8Array).toString("base64") }; + } + return v; + }); + +const revive = (value: unknown): unknown => { + if (value && typeof value === "object" && "__b" in (value as object)) { + return Buffer.from((value as { __b: string }).__b, "base64"); + } + if (Array.isArray(value)) return value.map(revive); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value as object).map(([k, v]) => [k, revive(v)])); + } + return value; +}; + +export function encodeBinaryNode(node: Node): Uint8Array { + const key = canon(node); + if (recording) { + const out = legacy.encodeBinaryNode(node); + vectors.encoded[key] = Buffer.from(out).toString("base64"); + persist(); + return out; + } + + const hit = vectors.encoded[key]; + if (!hit) throw new Error(`no recorded rc.9 encoding for <${node.tag}>`); + return new Uint8Array(Buffer.from(hit, "base64")); +} + +export async function decodeBinaryNode(buffer: Uint8Array): Promise { + const key = Buffer.from(buffer).toString("base64"); + if (recording) { + const out = await legacy.decodeBinaryNode(buffer); + vectors.decoded[key] = canon(out); + persist(); + return out; + } + + const hit = vectors.decoded[key]; + if (!hit) throw new Error("no recorded rc.9 decoding for this frame"); + return revive(JSON.parse(hit)); +} diff --git a/packages/whatsapp-rust-bridge/test/parity.test.ts b/packages/whatsapp-rust-bridge/test/parity.test.ts index 24a19d8efef..1ade3cfdec4 100644 --- a/packages/whatsapp-rust-bridge/test/parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/parity.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "@jest/globals"; import { encodeBinaryNode, decodeBinaryNode as legacyDecodeNode, -} from "baileys"; +} from "./helpers/legacy-wire"; import { decodeNode, encodeNode, type BinaryNode } from "../dist/index.js"; // Helper to visualize buffer differences diff --git a/packages/whatsapp-rust-bridge/test/server-response-parity.test.ts b/packages/whatsapp-rust-bridge/test/server-response-parity.test.ts index 0d1051d2eae..3bd46af4f8a 100644 --- a/packages/whatsapp-rust-bridge/test/server-response-parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/server-response-parity.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "@jest/globals"; import { encodeBinaryNode, decodeBinaryNode as legacyDecodeNode, -} from "baileys"; +} from "./helpers/legacy-wire"; import { decodeNode, encodeNode, type BinaryNode } from "../dist/index.js"; function hex(buffer: Uint8Array): string { From ecbdaf898080119f537685925cef7cfd4d46e7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Wed, 5 Aug 2026 23:54:52 -0300 Subject: [PATCH 64/71] fix(bridge): make the recorded wire vectors typecheck and round-trip Three problems with the vector harness, one of which broke CI. `decodeBinaryNode` returned `unknown`, so every caller reading `.tag` or `.attrs` failed the test typecheck. Jest never caught it because ts-jest does not typecheck; the CI step that runs tsc did. The buffer handling was inert. `Buffer.toJSON` runs before any replacer, so the `__b` branch was unreachable and the revive side looked for a shape that is never produced. Nothing broke yet because no recorded node carries binary content, but the first one that did would have come back as a plain object instead of a Buffer. Store and revive the shape that actually appears. The merge on write is read-modify-write, so recording has to be single-threaded. Say so where the instructions are, and write through a temp file so an interrupted run cannot leave truncated JSON behind. Also rename crypto-parity's helpers to nodeMd5/nodeHkdf and say node:crypto in the titles: the reference stopped being the JS client when the comparison moved to the standard algorithms. --- .../test/crypto-parity.test.ts | 36 ++++++------- .../test/helpers/legacy-wire-vectors.json | 2 +- .../test/helpers/legacy-wire.ts | 51 ++++++++++--------- .../test/snapshot_api.test.ts | 5 +- 4 files changed, 49 insertions(+), 45 deletions(-) diff --git a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts index 9b44b7686f9..4e2f3b02c6a 100644 --- a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts @@ -4,10 +4,10 @@ import { md5, hkdf } from "../dist/index.js"; // Node itself, not the JS client: MD5 and HKDF are standard, so the reference // is the algorithm rather than another implementation of it. -const baileysMd5 = (data: Uint8Array) => +const nodeMd5 = (data: Uint8Array) => createHash("md5").update(data).digest(); -const baileysHkdf = async ( +const nodeHkdf = async ( ikm: Uint8Array, length: number, { salt, info }: { salt?: Uint8Array; info?: string } = {}, @@ -26,54 +26,54 @@ function hex(buffer: Uint8Array | Buffer): string { return Buffer.from(buffer).toString("hex"); } -describe("Crypto Parity: MD5", () => { - it("should hash identically to Baileys", () => { +describe("Crypto Parity: MD5 vs node:crypto", () => { + it("should hash identically to node:crypto", () => { const data = Buffer.from("MD5 test data"); const wasmResult = md5(data); - const baileysResult = baileysMd5(data); + const nodeResult = nodeMd5(data); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); }); it("should hash empty buffer identically", () => { const data = Buffer.alloc(0); const wasmResult = md5(data); - const baileysResult = baileysMd5(data); + const nodeResult = nodeMd5(data); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); }); it("should hash large data identically", () => { const data = randomBytes(10000); const wasmResult = md5(data); - const baileysResult = baileysMd5(data); + const nodeResult = nodeMd5(data); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); }); }); -describe("Crypto Parity: HKDF", () => { - it("should derive keys identically to Baileys", async () => { +describe("Crypto Parity: HKDF vs node:crypto (RFC 5869)", () => { + it("should derive keys identically to node:crypto", async () => { const ikm = randomBytes(32); const salt = randomBytes(32); const info = "test info"; const wasmResult = hkdf(ikm, 64, { salt, info }); - const baileysResult = await baileysHkdf(ikm, 64, { salt, info }); + const nodeResult = await nodeHkdf(ikm, 64, { salt, info }); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); }); it("should derive with empty salt identically", async () => { const ikm = randomBytes(32); const wasmResult = hkdf(ikm, 32, {}); - const baileysResult = await baileysHkdf(ikm, 32, {}); + const nodeResult = await nodeHkdf(ikm, 32, {}); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); }); it("should derive different lengths identically", async () => { @@ -82,9 +82,9 @@ describe("Crypto Parity: HKDF", () => { for (const length of [16, 32, 48, 64, 128]) { const wasmResult = hkdf(ikm, length, { salt, info: undefined }); - const baileysResult = await baileysHkdf(ikm, length, { salt }); + const nodeResult = await nodeHkdf(ikm, length, { salt }); - expect(hex(wasmResult)).toBe(hex(baileysResult)); + expect(hex(wasmResult)).toBe(hex(nodeResult)); } }); }); diff --git a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json index 6a5f7c1f829..bceb51bcef9 100644 --- a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json @@ -1 +1 @@ -{"encoded":{"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"257131597\"}}":"APgHGRH6AAMEFAj/hSVxMVl/","{\"tag\":\"iq\",\"attrs\":{\"id\":\"3661.63898-1\",\"xmlns\":\"encrypt\",\"type\":\"get\",\"to\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"count\",\"attrs\":{}}]}":"APgKGQj/BjZhtjiYoRbLBCkR+gAD+AH4AUE=","{\"tag\":\"iq\",\"attrs\":{\"xmlns\":\"encrypt\",\"type\":\"set\",\"to\":\"@s.whatsapp.net\",\"id\":\"test-prekey-1\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}},{\"tag\":\"type\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[5]}},{\"tag\":\"identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[87,239,132,60,231,104,178,141,140,15,4,110,170,215,201,115,184,86,135,90,16,255,7,241,58,146,220,188,81,216,163,88]}},{\"tag\":\"list\",\"attrs\":{},\"content\":[{\"tag\":\"key\",\"attrs\":{},\"content\":[{\"tag\":\"id\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,1]}},{\"tag\":\"value\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[231,73,194,190,245,59,144,243,124,216,216,101,92,212,223,115,176,146,75,218,213,162,234,80,100,18,161,199,46,178,229,4]}}]}]}]}":"APgKGRbLBFoR+gADCPwNdGVzdC1wcmVrZXktMfgE+AKt/AQAAAD++AIE/AEF+AKc/CBX74Q852iyjYwPBG6q18lzuFaHWhD/B/E6kty8UdijWPgCcfgB+AKe+AL4Agj8AwAAAfgCbPwg50nCvvU7kPN82NhlXNTfc7CSS9rVoupQZBKhxy6y5QQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"2204545668\"},\"content\":[{\"tag\":\"pair-device-sign\",\"attrs\":{},\"content\":[{\"tag\":\"device-identity\",\"attrs\":{\"key-index\":\"19\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,34,64,231,116,71,48,212,66,147,169,148,223,4,5,170,170,217,243,47,180,75,82,214,34,85,101,183,215,193,254,105,26,99,151,89,101,95,29,50,178,32,143,182,227,139,160,172,199,250,144,135,202,61,77,132,218,60,16,160,194,193,109,81,198,49,3]}}]}]}":"APgIGRH6AAMEFAj/BSIEVFZo+AH4AvwQcGFpci1kZXZpY2Utc2lnbvgB+ATmUO2G/JgKEgizoJ/ZBhDD2sPJBhgTIAAoABpAlI91YIvr0nb7c4bLCJn5os6LksUgZ5PqbCLGxxSyipnKgHjo+a6oL28VQOp//04x9SqYQWbdNlPi7xvR5IzrCyJA53RHMNRCk6mU3wQFqqrZ8y+0S1LWIlVlt9fB/mkaY5dZZV8dMrIgj7bji6Csx/qQh8o9TYTaPBCgwsFtUcYxAw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"1422037390\"}}":"APgHGRH6AAMEFAj/BRQiA3OQ","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"21290.10000-1\"}}":"APgHGRH6AAMEKQj/hyEpCxAACh8=","{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}":"APgCJ/wGCAIIEggN","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}":"APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"dirty\",\"attrs\":{\"type\":\"account_sync\",\"timestamp\":\"1764814151\"}}]}":"APgE/AJpYgb6AAP4AfgF7QEE7G7smv8FF2SBQVE=","{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"}}":"APgDnXD/glFf","{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\"}}":"APgDOwz3AE3/BlWZhHJmYg==","{\"tag\":\"device\",\"attrs\":{\"jid\":\"236395184570386:77@lid\",\"lid\":\"236395184570386:77@lid\"}}":"APgFOwz3AU3/iCNjlRhFcDhvdvcBTf+II2OVGEVwOG8=","{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}":"APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test123\",\"xmlns\":\"encrypt\"}}":"APgJGRH6AAMEFAj8B3Rlc3QxMjMWyw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\"}}":"APgFGRH6AAMEFA==","{\"tag\":\"iq\",\"attrs\":{\"type\":\"result\",\"to\":\"@s.whatsapp.net\"}}":"APgFGQQUEfoAAw==","{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}":"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==","{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}":"APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}":"APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}":"APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}":"APgE/AJpYgb6AAP4AfgDEkEt","{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}":"APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=","{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}":"APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==","{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}":"APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=","{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}":"APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}":"APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}":"APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA","{\"tag\":\"iq\",\"attrs\":{\"to\":\"s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGREDBFoIVgA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgGExH6/wUSNFZ4kAMI/AVtc2ctMQA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}":"APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=","{\"tag\":\"message\",\"attrs\":{},\"content\":\"Hello World\"}":"APgCE/wLSGVsbG8gV29ybGQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGRH6AAMEWghWAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}":"APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA","{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}":"APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}":"APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}":"APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE="},"decoded":{"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}","APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}","APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==":"{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}","APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}","APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}","APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}","APgE/AJpYgb6AAP4AfgDEkEt":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}","APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}","APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==":"{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}","APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}","APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=":"{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}","APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}","APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}","APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}","APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}","APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}","APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}","APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}","APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE=":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}","APgEnXD/glFfAA==":"{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"},\"content\":[]}"}} \ No newline at end of file +{"encoded":{"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}":"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==","{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}":"APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==","{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}":"APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}":"APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}":"APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}":"APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}":"APgE/AJpYgb6AAP4AfgDEkEt","{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}":"APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=","{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}":"APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==","{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}":"APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=","{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}":"APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=","{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}":"APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}":"APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA","{\"tag\":\"iq\",\"attrs\":{\"to\":\"s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGREDBFoIVgA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgGExH6/wUSNFZ4kAMI/AVtc2ctMQA=","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}":"APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=","{\"tag\":\"message\",\"attrs\":{},\"content\":\"Hello World\"}":"APgCE/wLSGVsbG8gV29ybGQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"ping\"},\"content\":[]}":"APgIGRH6AAMEWghWAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}":"APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}":"APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA","{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}":"APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==","{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}":"APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}":"APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"257131597\"}}":"APgHGRH6AAMEFAj/hSVxMVl/","{\"tag\":\"iq\",\"attrs\":{\"id\":\"3661.63898-1\",\"xmlns\":\"encrypt\",\"type\":\"get\",\"to\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"count\",\"attrs\":{}}]}":"APgKGQj/BjZhtjiYoRbLBCkR+gAD+AH4AUE=","{\"tag\":\"iq\",\"attrs\":{\"xmlns\":\"encrypt\",\"type\":\"set\",\"to\":\"@s.whatsapp.net\",\"id\":\"test-prekey-1\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}},{\"tag\":\"type\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[5]}},{\"tag\":\"identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[87,239,132,60,231,104,178,141,140,15,4,110,170,215,201,115,184,86,135,90,16,255,7,241,58,146,220,188,81,216,163,88]}},{\"tag\":\"list\",\"attrs\":{},\"content\":[{\"tag\":\"key\",\"attrs\":{},\"content\":[{\"tag\":\"id\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,1]}},{\"tag\":\"value\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[231,73,194,190,245,59,144,243,124,216,216,101,92,212,223,115,176,146,75,218,213,162,234,80,100,18,161,199,46,178,229,4]}}]}]}]}":"APgKGRbLBFoR+gADCPwNdGVzdC1wcmVrZXktMfgE+AKt/AQAAAD++AIE/AEF+AKc/CBX74Q852iyjYwPBG6q18lzuFaHWhD/B/E6kty8UdijWPgCcfgB+AKe+AL4Agj8AwAAAfgCbPwg50nCvvU7kPN82NhlXNTfc7CSS9rVoupQZBKhxy6y5QQ=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"2204545668\"},\"content\":[{\"tag\":\"pair-device-sign\",\"attrs\":{},\"content\":[{\"tag\":\"device-identity\",\"attrs\":{\"key-index\":\"19\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,34,64,231,116,71,48,212,66,147,169,148,223,4,5,170,170,217,243,47,180,75,82,214,34,85,101,183,215,193,254,105,26,99,151,89,101,95,29,50,178,32,143,182,227,139,160,172,199,250,144,135,202,61,77,132,218,60,16,160,194,193,109,81,198,49,3]}}]}]}":"APgIGRH6AAMEFAj/BSIEVFZo+AH4AvwQcGFpci1kZXZpY2Utc2lnbvgB+ATmUO2G/JgKEgizoJ/ZBhDD2sPJBhgTIAAoABpAlI91YIvr0nb7c4bLCJn5os6LksUgZ5PqbCLGxxSyipnKgHjo+a6oL28VQOp//04x9SqYQWbdNlPi7xvR5IzrCyJA53RHMNRCk6mU3wQFqqrZ8y+0S1LWIlVlt9fB/mkaY5dZZV8dMrIgj7bji6Csx/qQh8o9TYTaPBCgwsFtUcYxAw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"1422037390\"}}":"APgHGRH6AAMEFAj/BRQiA3OQ","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"21290.10000-1\"}}":"APgHGRH6AAMEKQj/hyEpCxAACh8=","{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}":"APgCJ/wGCAIIEggN","{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"dirty\",\"attrs\":{\"type\":\"account_sync\",\"timestamp\":\"1764814151\"}}]}":"APgE/AJpYgb6AAP4AfgF7QEE7G7smv8FF2SBQVE=","{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"}}":"APgDnXD/glFf","{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\"}}":"APgDOwz3AE3/BlWZhHJmYg==","{\"tag\":\"device\",\"attrs\":{\"jid\":\"236395184570386:77@lid\",\"lid\":\"236395184570386:77@lid\"}}":"APgFOwz3AU3/iCNjlRhFcDhvdvcBTf+II2OVGEVwOG8=","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test123\",\"xmlns\":\"encrypt\"}}":"APgJGRH6AAMEFAj8B3Rlc3QxMjMWyw==","{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\"}}":"APgFGRH6AAMEFA==","{\"tag\":\"iq\",\"attrs\":{\"type\":\"result\",\"to\":\"@s.whatsapp.net\"}}":"APgFGQQUEfoAAw=="},"decoded":{"APgKGQb6AAMEWgj/hSVxMVl/FvwCbWT4AfgC7e74AfgC7VD8ZjJAdXhkRTAzbVJOQlhpa3lLbFR2TTFIZGQvQlRuM2dqdlNTSk82TW1CMXQrbFh4RU0vMFBiWVlObnNqM1UrTWV0a3MwWFpacGhGRzIrVmhnaExqeFRyMVh5eHJKZ1g3b1JZT2RJPQ==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"257131597\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-device\",\"attrs\":{},\"content\":[{\"tag\":\"ref\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[50,64,117,120,100,69,48,51,109,82,78,66,88,105,107,121,75,108,84,118,77,49,72,100,100,47,66,84,110,51,103,106,118,83,83,74,79,54,77,109,66,49,116,43,108,88,120,69,77,47,48,80,98,89,89,78,110,115,106,51,85,43,77,101,116,107,115,48,88,90,90,112,104,70,71,50,43,86,104,103,104,76,106,120,84,114,49,88,121,120,114,74,103,88,55,111,82,89,79,100,73,61]}}]}]}","APgKGQb6AAMEWgj/BSIEVFZoFvwCbWT4AfgC/AxwYWlyLXN1Y2Nlc3P4BPgC/AxjbGllbnQtcHJvcHP8BBAAGAH4A0qJ5PgC5vyeCngKEgizoJ/ZBhDD2sPJBhgTIAAoABIgtQOLMv7U/wBAg3NVtXyOFP5ZF9qNqJbLrMf9qnwygCYaQJSPdWCL69J2+3OGywiZ+aLOi5LFIGeT6mwixscUsoqZyoB46PmuqC9vFUDqf/9OMfUqmEFm3TZT4u8b0eSM6wsSIIlB8gXQ8/DuccSvU//yx4/SJDSWWyvXL0KRaiLvIFmnGAD4BTsM9wBN/wZVmYRyZmJ29wFN/4gjY5UYRXA4bw==":"{\"tag\":\"iq\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"set\",\"id\":\"2204545668\",\"xmlns\":\"md\"},\"content\":[{\"tag\":\"pair-success\",\"attrs\":{},\"content\":[{\"tag\":\"client-props\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[16,0,24,1]}},{\"tag\":\"platform\",\"attrs\":{\"name\":\"android\"}},{\"tag\":\"device-identity\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[10,120,10,18,8,179,160,159,217,6,16,195,218,195,201,6,24,19,32,0,40,0,18,32,181,3,139,50,254,212,255,0,64,131,115,85,181,124,142,20,254,89,23,218,141,168,150,203,172,199,253,170,124,50,128,38,26,64,148,143,117,96,139,235,210,118,251,115,134,203,8,153,249,162,206,139,146,197,32,103,147,234,108,34,198,199,20,178,138,153,202,128,120,232,249,174,168,47,111,21,64,234,127,255,78,49,245,42,152,65,102,221,54,83,226,239,27,209,228,140,235,11,18,32,137,65,242,5,208,243,240,238,113,196,175,83,255,242,199,143,210,36,52,150,91,43,215,47,66,145,106,34,239,32,89,167,24,0]}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"lid\":\"236395184570386:77@lid\"}}]}]}","APgPTBr/BRdkgUFRXO4EPfwDbGxhdvcBTf+II2OVGEVwOG9L7LQ8/wUXZIFBSOzm/CxqNzFlVDRkSk9kZUs1eVRZeVJQVm5FOXpHR3RBVjJLVlp3VTNxTzFGQ3FjPQ==":"{\"tag\":\"success\",\"attrs\":{\"t\":\"1764814151\",\"props\":\"27\",\"location\":\"lla\",\"lid\":\"236395184570386:77@lid\",\"abprops\":\"10\",\"creation\":\"1764814148\",\"companion_enc_static\":\"j71eT4dJOdeK5yTYyRPVnE9zGGtAV2KVZwU3qO1FCqc=\"}}","APgE/AJpYgb6AAP4AfgCKPgB+AIn/AYIAggSCA0=":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"edge_routing\",\"attrs\":{},\"content\":[{\"tag\":\"routing_info\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[8,2,8,18,8,13]}}]}]}","APgKCQb6/wZVmYRyZmIDBOxuCP+FiUJnMY8a/wUXZIFBUfgB+AQP7Nb8CjI6WXlsUytJTTP4A/gDOwz6/wZVmYRyZmID+AU7DPcAO/8GVZmEcmZiUFX4BTsM9wBN/wZVmYRyZmJQ7YY=":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"account_sync\",\"id\":\"894267318\",\"t\":\"1764814151\"},\"content\":[{\"tag\":\"devices\",\"attrs\":{\"dhash\":\"2:YylS+IM3\"},\"content\":[{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662@s.whatsapp.net\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:59@s.whatsapp.net\",\"key-index\":\"1\"}},{\"tag\":\"device\",\"attrs\":{\"jid\":\"559984726662:77@s.whatsapp.net\",\"key-index\":\"19\"}}]}]}","APgKCQb6AAME7MsI/wSSQFJAGv8FF2SBQVL4AfgF7FuJ7ShU/wFm":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"@s.whatsapp.net\",\"type\":\"server_sync\",\"id\":\"92405240\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"collection\",\"attrs\":{\"name\":\"regular_low\",\"version\":\"66\"}}]}","APgE/AJpYgb6AAP4AfgDEkEt":"{\"tag\":\"ib\",\"attrs\":{\"from\":\"@s.whatsapp.net\"},\"content\":[{\"tag\":\"offline\",\"attrs\":{\"count\":\"0\"}}]}","APgMEwb6/wZVmYRyZmIDBDgI+xChSvpJxNmu2mnwGt3yKJ1xge54Gv8FF2SBQVL4AvgD7X2A7Nr4Bh1RRQRT/CQzEiEF5hQT7Yba6HjNb715nVhhPcVlzYY3MSw+qiqgvdCItHA=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"text\",\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\",\"category\":\"peer\",\"t\":\"1764814152\"},\"content\":[{\"tag\":\"meta\",\"attrs\":{\"appdata\":\"default\"}},{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[51,18,33,5,230,20,19,237,134,218,232,120,205,111,189,121,157,88,97,61,197,101,205,134,55,49,44,62,170,42,160,189,208,136,180,112]}}]}","APgD/AR0ZXN0CPsQoUr6ScTZrtpp8Brd8iidcQ==":"{\"tag\":\"test\",\"attrs\":{\"id\":\"A14AFA49C4D9AEDA69F01ADDF2289D71\"}}","APgMEwb6/wkSA2MhQEgHZRQcBDgI+ws+sNWPGwmp1/mhIwX6/wZVmYRyZmIDGv8FF2SBQgD4AfgGHVFFBDL8BKvNEjQ=":"{\"tag\":\"message\",\"attrs\":{\"from\":\"120363214048076514@g.us\",\"type\":\"text\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"participant\":\"559984726662@s.whatsapp.net\",\"t\":\"1764814200\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"skmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[171,205,18,52]}}]}","APgKBwb6/wZVmYRyZmIDCPsLPrDVjxsJqdf5oSME7Asa/wUXZIFDAPgB+ArsC0FVUVUI+ws+sNWPGwmp1/mhIxr/BRdkgUJQ+AH4Aq38BAAAAP4=":"{\"tag\":\"receipt\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"type\":\"retry\",\"t\":\"1764814300\"},\"content\":[{\"tag\":\"retry\",\"attrs\":{\"count\":\"1\",\"v\":\"1\",\"id\":\"3EB0D58F1B09A9D7F9A123\",\"t\":\"1764814250\"},\"content\":[{\"tag\":\"registration\",\"attrs\":{},\"content\":{\"type\":\"Buffer\",\"data\":[0,0,0,254]}}]}]}","APgKCQb6/wZVmYRyZmIDBO4OCPwQdGVzdC1oaXN0b3J5LTEyMxr/BRdkgUQA+AH4Bh1RRQRT/AQKEggV":"{\"tag\":\"notification\",\"attrs\":{\"from\":\"559984726662@s.whatsapp.net\",\"type\":\"mediaretry\",\"id\":\"test-history-123\",\"t\":\"1764814400\"},\"content\":[{\"tag\":\"enc\",\"attrs\":{\"v\":\"2\",\"type\":\"pkmsg\"},\"content\":{\"type\":\"Buffer\",\"data\":[10,18,8,21]}}]}","APgIGRH6AAMEKQj8CHRlc3QtMTIzAA==":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"get\",\"id\":\"test-123\"},\"content\":[]}","APgKGRH6ABwEKRbtMwj8CnRlc3QtZ3JvdXAA":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@g.us\",\"type\":\"get\",\"xmlns\":\"w:g2\",\"id\":\"test-group\"},\"content\":[]}","APgIExH6/wUSNFZ4kAMG+v8FCYdlQyEDCPwFbXNnLTEA":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890@s.whatsapp.net\",\"from\":\"0987654321@s.whatsapp.net\",\"id\":\"msg-1\"},\"content\":[]}","APgGExH3AAL/BRI0VniQCPwKbXNnLWRldmljZQA=":"{\"tag\":\"message\",\"attrs\":{\"to\":\"1234567890:2@s.whatsapp.net\",\"id\":\"msg-device\"},\"content\":[]}","APgGExH6/4gSNFZ4kBI0X3YI/Adtc2ctbGlkAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"123456789012345@lid\",\"id\":\"msg-lid\"},\"content\":[]}","APgGExH6/wkSA2MhQEgHZRQcCPwJbXNnLWdyb3VwAA==":"{\"tag\":\"message\",\"attrs\":{\"to\":\"120363214048076514@g.us\",\"id\":\"msg-group\"},\"content\":[]}","APgIGRH6AAMEFAj8C3Rlc3QtbmVzdGVk+AL4AwUM+v8FEjRWeJAD+AMFDPcAAf8FCYdlQyE=":"{\"tag\":\"iq\",\"attrs\":{\"to\":\"@s.whatsapp.net\",\"type\":\"result\",\"id\":\"test-nested\"},\"content\":[{\"tag\":\"participant\",\"attrs\":{\"jid\":\"1234567890@s.whatsapp.net\"}},{\"tag\":\"participant\",\"attrs\":{\"jid\":\"0987654321:1@s.whatsapp.net\"}}]}","APgEnXD/glFfAA==":"{\"tag\":\"stream:error\",\"attrs\":{\"code\":\"515\"},\"content\":[]}"}} \ No newline at end of file diff --git a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts index 672b845578c..99d164219a8 100644 --- a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts @@ -6,10 +6,11 @@ * throws instead of silently passing. * * To re-record after adding a case: `pnpm add -D baileys@7.0.0-rc.9`, run - * `RECORD_LEGACY_VECTORS=1 pnpm test parity`, commit the JSON, drop the dep. + * `RECORD_LEGACY_VECTORS=1 pnpm test parity -- --runInBand`, commit the JSON, + * then drop the dep again. */ import { createRequire } from "node:module"; -import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -25,33 +26,37 @@ const vectors: { encoded: Record; decoded: Record { const onDisk = existsSync(store) ? JSON.parse(readFileSync(store, "utf8")) : { encoded: {}, decoded: {} }; - writeFileSync( - store, - JSON.stringify({ - encoded: { ...onDisk.encoded, ...vectors.encoded }, - decoded: { ...onDisk.decoded, ...vectors.decoded }, - }), - ); + const merged = JSON.stringify({ + encoded: { ...onDisk.encoded, ...vectors.encoded }, + decoded: { ...onDisk.decoded, ...vectors.decoded }, + }); + writeFileSync(store + ".tmp", merged); + renameSync(store + ".tmp", store); }; -/** Key order is significant: the wire format encodes attributes in order. */ -const canon = (value: unknown): string => - JSON.stringify(value, (_, v) => { - if (v instanceof Uint8Array || Buffer.isBuffer(v)) { - return { __b: Buffer.from(v as Uint8Array).toString("base64") }; - } - return v; - }); +/** + * Key order is significant: the wire format encodes attributes in order. + * + * Buffers serialize as `{ type: 'Buffer', data: [...] }` because `toJSON` runs + * before any replacer would, so that is the shape stored and revived. + */ +const canon = (value: unknown): string => JSON.stringify(value); + +type BufferJson = { type: "Buffer"; data: number[] }; + +const isBufferJson = (value: unknown): value is BufferJson => + typeof value === "object" && + value !== null && + (value as BufferJson).type === "Buffer" && + Array.isArray((value as BufferJson).data); const revive = (value: unknown): unknown => { - if (value && typeof value === "object" && "__b" in (value as object)) { - return Buffer.from((value as { __b: string }).__b, "base64"); - } + if (isBufferJson(value)) return Buffer.from(value.data); if (Array.isArray(value)) return value.map(revive); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value as object).map(([k, v]) => [k, revive(v)])); @@ -73,7 +78,7 @@ export function encodeBinaryNode(node: Node): Uint8Array { return new Uint8Array(Buffer.from(hit, "base64")); } -export async function decodeBinaryNode(buffer: Uint8Array): Promise { +export async function decodeBinaryNode(buffer: Uint8Array): Promise { const key = Buffer.from(buffer).toString("base64"); if (recording) { const out = await legacy.decodeBinaryNode(buffer); @@ -84,5 +89,5 @@ export async function decodeBinaryNode(buffer: Uint8Array): Promise { const hit = vectors.decoded[key]; if (!hit) throw new Error("no recorded rc.9 decoding for this frame"); - return revive(JSON.parse(hit)); + return revive(JSON.parse(hit)) as Node; } diff --git a/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts index a1dc82c96ac..aff7962e34c 100644 --- a/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts +++ b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts @@ -74,10 +74,9 @@ function snapshotOf( } as SignalSnapshot; } -/** Opens a session from alice towards bob using the callback API (setup only). */ +/** Opens a session from alice towards bob (setup only). */ async function establish(alice: Party, bob: Party, bobAddr: ProtocolAddress) { - // The same call the consumer makes; SessionBuilder is gone with the rest of - // the callback-based session path. + // The same call the consumer makes. const out = await processBundleWithSnapshot(snapshotOf(alice), bobAddr, { registrationId: bob.registrationId, identityKey: prefixed(bob.identity.public), From 01c56a0d40fb9601208522a4a6ab0807b98c54a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 00:03:25 -0300 Subject: [PATCH 65/71] test(bridge): reject a malformed Buffer in a recorded vector The revive guard matched any object with type "Buffer" and an array under data. Buffer.from truncates out-of-range values rather than rejecting them, so a hand-edited or truncated vector would have revived as wrong bytes and the comparison would have run against them. Check the byte range and the exact key set, and throw when something claims to be a Buffer but is not one, so the failure names itself. --- .../test/helpers/legacy-wire.ts | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts index 99d164219a8..6376ba9ff26 100644 --- a/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts @@ -49,14 +49,26 @@ const canon = (value: unknown): string => JSON.stringify(value); type BufferJson = { type: "Buffer"; data: number[] }; -const isBufferJson = (value: unknown): value is BufferJson => - typeof value === "object" && - value !== null && - (value as BufferJson).type === "Buffer" && - Array.isArray((value as BufferJson).data); +const isBufferJson = (value: unknown): value is BufferJson => { + if (typeof value !== "object" || value === null) return false; + const candidate = value as BufferJson; + if (candidate.type !== "Buffer" || !Array.isArray(candidate.data)) return false; + if (Object.keys(candidate).length !== 2) return false; + // Buffer.from truncates out-of-range values instead of rejecting them, so a + // hand-edited or truncated vector would revive as wrong bytes in silence. + return candidate.data.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); +}; const revive = (value: unknown): unknown => { if (isBufferJson(value)) return Buffer.from(value.data); + if ( + typeof value === "object" && + value !== null && + (value as { type?: unknown }).type === "Buffer" + ) { + throw new Error("recorded vector has a malformed Buffer"); + } + if (Array.isArray(value)) return value.map(revive); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value as object).map(([k, v]) => [k, revive(v)])); From be2f94e74ba25bb20f056fc9f88fae2031a0478f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 00:20:02 -0300 Subject: [PATCH 66/71] chore(bridge): drop five unused dependencies hashify, hmac, prost, serde-wasm-bindgen and simd-adler32 are declared but never referenced. prost in particular is not a leftover from the core: the core does not depend on it at all, so it entered the lockfile purely through this declaration. Two that cargo-machete also flagged stay, and now say why. getrandom is never called; it is there to turn on the wasm_js backend, without which the wasm32 target refuses to build. rand's sys_rng is what make_rng falls back to for seeding, and while another crate happens to enable it today, key generation must not depend on that staying true. Nothing changes in the artifact: dead code elimination was already dropping these, so the wasm is byte-identical at 959,313. The lockfile loses 26 lines and two crates. --- packages/whatsapp-rust-bridge/Cargo.lock | 26 ------------------------ packages/whatsapp-rust-bridge/Cargo.toml | 11 ++++------ 2 files changed, 4 insertions(+), 33 deletions(-) diff --git a/packages/whatsapp-rust-bridge/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index 8bb409d6f12..65741896b45 100644 --- a/packages/whatsapp-rust-bridge/Cargo.lock +++ b/packages/whatsapp-rust-bridge/Cargo.lock @@ -577,18 +577,6 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" -[[package]] -name = "hashify" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" -dependencies = [ - "indexmap", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "heck" version = "0.5.0" @@ -897,15 +885,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", -] - [[package]] name = "pxfm" version = "0.1.29" @@ -1690,22 +1669,17 @@ dependencies = [ "bytes", "curve25519-dalek 4.1.3", "getrandom", - "hashify", "hkdf 0.12.4", - "hmac 0.12.1", "image", "img-parts", "js-sys", "log", "md-5", - "prost", "rand", "serde", - "serde-wasm-bindgen", "serde_bytes", "serde_json", "sha2 0.10.9", - "simd-adler32", "symphonia", "tsify", "uuid", diff --git a/packages/whatsapp-rust-bridge/Cargo.toml b/packages/whatsapp-rust-bridge/Cargo.toml index 52c9b294154..445adb944de 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -52,10 +52,10 @@ curve25519-dalek = { version = "4.1.3", default-features = false, features = [ "digest", "precomputed-tables", ] } +# Not used directly: pulled in to turn on the wasm_js backend, which the +# wasm32 target refuses to build without. getrandom = { version = "0.4", features = ["wasm_js"] } -hashify = { version = "0.2.9", default-features = false, features = ["force-32bit"] } hkdf = "0.12" -hmac = "0.12" image = { version = "0.25.5", default-features = false, features = [ "jpeg", "png", @@ -65,7 +65,8 @@ img-parts = { version = "0.4", default-features = false, optional = true } js-sys = "0.3" log = "0.4" md-5 = "0.10" -prost = { version = "0.14.1", default-features = false } +# sys_rng is what make_rng falls back to for seeding; another crate happens to +# turn it on today, but key generation must not depend on that staying true. rand = { version = "0.10", default-features = false, features = [ "std", "std_rng", @@ -75,13 +76,9 @@ serde = { version = "1.0.228", default-features = false, features = [ "derive", "alloc", ] } -serde-wasm-bindgen = "0.6.5" serde_bytes = "0.11" serde_json = { version = "1.0", default-features = false, features = ["alloc"] } sha2 = "0.10" -simd-adler32 = { version = "0.3.7", default-features = false, features = [ - "std", -] } symphonia = { version = "0.5.4", default-features = false, features = [ "mp3", "aac", From 2a904a2e5fca81f6fa87b111225e413689c42098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 00:26:10 -0300 Subject: [PATCH 67/71] fix(signal): read a pre-v1 legacy session record libsignal's deserialize runs a v1 migration that copies a record-level registrationId onto every entry missing one, and haveOpenSession then requires the entry to have it. An auth state written before that migration still keeps the id only at the top. We required it on the entry, so for such a record every entry looked unusable: hasOpenLegacySession returned false, readSessionBytes reported no session, and pending whisper messages failed with SessionNotFound after the upgrade. The session was live the whole time. Fall back to the record-level id in both the open-session check and the conversion, which is the same migration libsignal does. --- .../src/Signal/legacy-session-codec.ts | 9 ++--- packages/baileys/src/Signal/legacy-session.ts | 22 ++++++++++-- .../src/__tests__/Signal/legacy-codec.test.ts | 36 +++++++++++++++++++ 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/baileys/src/Signal/legacy-session-codec.ts b/packages/baileys/src/Signal/legacy-session-codec.ts index d5a4deef9b4..9ea33fcbc94 100644 --- a/packages/baileys/src/Signal/legacy-session-codec.ts +++ b/packages/baileys/src/Signal/legacy-session-codec.ts @@ -76,15 +76,16 @@ const toTypedChain = (ratchetKey: string, chain: LegacyChainJson): LegacySession } } -const toTypedSession = (entry: LegacyEntryJson): LegacySessionV1 => { +const toTypedSession = (entry: LegacyEntryJson, recordRegistrationId?: number): LegacySessionV1 => { const ratchet = entry.currentRatchet const index = entry.indexInfo - if (!ratchet || !index || typeof entry.registrationId !== 'number') { + const registrationId = typeof entry.registrationId === 'number' ? entry.registrationId : recordRegistrationId + if (!ratchet || !index || typeof registrationId !== 'number') { throw new TypeError('legacy session: entry is missing registrationId/currentRatchet/indexInfo') } return { - registrationId: entry.registrationId, + registrationId, ratchet: { keyPair: { public: decode(ratchet.ephemeralKeyPair?.pubKey, 'ephemeralKeyPair.pubKey'), @@ -122,7 +123,7 @@ export const toTypedRecord = (record: LegacySessionRecord): LegacySessionRecordV .filter((pair): pair is [string, LegacyEntryJson] => pair[1] !== undefined && pair[1] !== null) .map(([indexKey, entry]) => ({ indexKey: decode(indexKey, 'session index key'), - session: toTypedSession(entry) + session: toTypedSession(entry, record.registrationId) })) return { sessions } diff --git a/packages/baileys/src/Signal/legacy-session.ts b/packages/baileys/src/Signal/legacy-session.ts index dcd74e02017..5b2a067bca9 100644 --- a/packages/baileys/src/Signal/legacy-session.ts +++ b/packages/baileys/src/Signal/legacy-session.ts @@ -23,6 +23,12 @@ export type LegacySessionEntry = { export type LegacySessionRecord = { _sessions?: { [baseKey: string]: LegacySessionEntry | undefined } version?: string + /** + * Pre-v1 records carry the id here instead of on each entry. libsignal's + * deserialize copies it down as a migration, so a record in that shape has + * live sessions that look unusable until the same thing is done here. + */ + registrationId?: number } /** libsignal marks a live session with `closed === -1`. */ @@ -31,8 +37,18 @@ const OPEN = -1 export const isLegacySessionRecord = (value: unknown): value is LegacySessionRecord => typeof value === 'object' && value !== null && !ArrayBuffer.isView(value) && '_sessions' in value -const isUsableEntry = (entry: LegacySessionEntry | undefined): entry is LegacySessionEntry => - !!entry && typeof entry.registrationId === 'number' && !!entry.currentRatchet +/** The entry's own id, or the record's for a pre-v1 shape. */ +export const entryRegistrationId = ( + entry: LegacySessionEntry | undefined, + record: LegacySessionRecord +): number | undefined => + typeof entry?.registrationId === 'number' ? entry.registrationId : record.registrationId + +const isUsableEntry = ( + entry: LegacySessionEntry | undefined, + record: LegacySessionRecord +): entry is LegacySessionEntry => + !!entry && typeof entryRegistrationId(entry, record) === 'number' && !!entry.currentRatchet /** * The live session state, or undefined when every state is closed (or the @@ -40,7 +56,7 @@ const isUsableEntry = (entry: LegacySessionEntry | undefined): entry is LegacySe */ export const pickOpenLegacySession = (record: LegacySessionRecord): LegacySessionEntry | undefined => { for (const entry of Object.values(record._sessions || {})) { - if (isUsableEntry(entry) && entry.indexInfo?.closed === OPEN) { + if (isUsableEntry(entry, record) && entry.indexInfo?.closed === OPEN) { return entry } } diff --git a/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts index a9898d048e9..8567e6bb86c 100644 --- a/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts +++ b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from '@jest/globals' import P from 'pino' import { importLegacySessionRecordV1, projectLegacySessionRecordV1 } from 'whatsapp-rust-bridge' +import { hasOpenLegacySession } from '../../Signal/legacy-session' import { fromTypedRecord, toTypedRecord } from '../../Signal/legacy-session-codec' import { makeLibSignalRepository } from '../../Signal/libsignal' import type { SignalAuthState, SignalDataSet, SignalDataTypeMap, SignalKeyStore } from '../../Types' @@ -167,3 +168,38 @@ describe('legacy session codec', () => { expect(Buffer.from(second)).toEqual(Buffer.from(first)) }) }) + +/** + * libsignal's own deserialize runs a v1 migration that copies a record-level + * `registrationId` onto every entry that lacks one. An auth state written + * before that migration still has the id only at the top, and reading such a + * record as if the entries were unusable drops sessions that are live. + */ +describe('pre-v1 legacy records', () => { + const preV1 = () => { + const record = JSON.parse(JSON.stringify(legacyBobSession)) as { + _sessions: Record + registrationId?: number + } + const [entry] = Object.values(record._sessions) + record.registrationId = entry!.registrationId + delete entry!.registrationId + return record + } + + it('counts the session as open using the record-level id', () => { + const record = preV1() + + expect(Object.values(record._sessions)[0]!.registrationId).toBeUndefined() + expect(hasOpenLegacySession(record as never)).toBe(true) + }) + + it('imports it instead of reporting no session', () => { + const bytes = importLegacySessionRecordV1(toTypedRecord(preV1() as never), { + identityKey: generateSignalPubKey(bobCreds.signedIdentityKey.public), + registrationId: bobCreds.registrationId + }) + + expect(bytes.length).toBeGreaterThan(0) + }) +}) From 182ce3a94dbee33730000c216f2946d3e1e7e275 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 00:34:49 -0300 Subject: [PATCH 68/71] fix(signal): satisfy prettier, and drop the libsignal-node mention The ternary in entryRegistrationId needed parentheses. AGENTS.md still described Signal/ as wrapping libsignal-node, which this branch replaced with the bridge. --- AGENTS.md | 2 +- packages/baileys/src/Signal/legacy-session.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d991c6a2764..b0ce59d9f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ packages/ baileys/ The npm `baileys` library src/ Socket/ High-level socket — chats, groups, messages send/recv, newsletter, USync - Signal/ Signal Protocol session/sender-key wrapping over libsignal-node + Signal/ Signal Protocol session/sender-key wrapping over whatsapp-rust-bridge Utils/ Decoding, media, auth state, retry, app-state sync, generics Types/ Public TypeScript types — touching these is a public-API change WABinary/ Binary node encoding/decoding diff --git a/packages/baileys/src/Signal/legacy-session.ts b/packages/baileys/src/Signal/legacy-session.ts index 5b2a067bca9..11c84c563bf 100644 --- a/packages/baileys/src/Signal/legacy-session.ts +++ b/packages/baileys/src/Signal/legacy-session.ts @@ -41,8 +41,7 @@ export const isLegacySessionRecord = (value: unknown): value is LegacySessionRec export const entryRegistrationId = ( entry: LegacySessionEntry | undefined, record: LegacySessionRecord -): number | undefined => - typeof entry?.registrationId === 'number' ? entry.registrationId : record.registrationId +): number | undefined => (typeof entry?.registrationId === 'number' ? entry.registrationId : record.registrationId) const isUsableEntry = ( entry: LegacySessionEntry | undefined, From 01a8f56c798e4b10e1a5a1526d531c50d5530e1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 00:41:04 -0300 Subject: [PATCH 69/71] test(bridge): handle the Result from add_sender_key_state The only warning left in the crate, in a test helper that dropped the Result on the floor. Now the build is clean under clippy with -D warnings across every target and feature. --- .../src/storage_adapter.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/whatsapp-rust-bridge/src/storage_adapter.rs b/packages/whatsapp-rust-bridge/src/storage_adapter.rs index 5b6616518a3..c7dda34f5cf 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -573,14 +573,16 @@ mod legacy_sender_key_tests { let mut rng = rand::make_rng::(); let signing = KeyPair::generate(&mut rng); let mut record = SenderKeyRecord::new_empty(); - record.add_sender_key_state( - 3, - 7, - 42, - &[9u8; 32], - signing.public_key, - Some(signing.private_key), - ); + record + .add_sender_key_state( + 3, + 7, + 42, + &[9u8; 32], + signing.public_key, + Some(signing.private_key), + ) + .expect("valid state"); record } From b3026310a3cec764c95241a838c54ec45b2ee22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 13:28:57 -0300 Subject: [PATCH 70/71] test(bridge): port the signal bench to the snapshot calls Dropping SessionCipher and SessionBuilder left this bench importing them, so `pnpm bench` died on the second file. It now holds the snapshot itself and applies each changeset, which is what a consumer does; the wrapper keeps the old method names so the benchmark bodies are unchanged. Still 7.5x, 9.6x and 4.0x faster than libsignal-node on encrypt, decrypt and round trip. --- .../whatsapp-rust-bridge/benches/signal.ts | 97 +++++++++++++++---- 1 file changed, 79 insertions(+), 18 deletions(-) diff --git a/packages/whatsapp-rust-bridge/benches/signal.ts b/packages/whatsapp-rust-bridge/benches/signal.ts index 2c50ef667d5..c5f66a97f38 100644 --- a/packages/whatsapp-rust-bridge/benches/signal.ts +++ b/packages/whatsapp-rust-bridge/benches/signal.ts @@ -1,10 +1,13 @@ import { run, bench, do_not_optimize, boxplot, summary } from "mitata"; import { ProtocolAddress, - SessionBuilder, - SessionCipher, + decryptPreKeyWithSnapshot, + decryptWhisperWithSnapshot, + encryptWithSnapshot, generateSignedPreKey, generatePreKey, + processBundleWithSnapshot, + type SignalSnapshot, } from "../dist/index.js"; import { FakeStorage } from "../test/helpers/fake_storage.ts"; @@ -101,9 +104,52 @@ class LibsignalStore implements SignalStorage { // has a chance to catch up. // ============================================================================ +/** + * The snapshot calls are pure functions over a caller-held state, so this keeps + * that state and applies each changeset, which is what the consumer does. The + * shape mirrors libsignal-node's cipher so both sides of the comparison read + * the same. + */ +class SnapshotCipher { + constructor( + private snapshot: SignalSnapshot, + private peer: ProtocolAddress, + ) {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private apply(changes: any) { + if (changes.session) this.snapshot = { ...this.snapshot, session: changes.session }; + if (changes.identity) this.snapshot = { ...this.snapshot, peerIdentity: changes.identity }; + if (changes.removedPreKeyId !== undefined) { + this.snapshot = { + ...this.snapshot, + preKeys: this.snapshot.preKeys.filter((k) => k.id !== changes.removedPreKeyId), + }; + } + } + + async encrypt(plaintext: Uint8Array) { + const out = await encryptWithSnapshot(this.snapshot, this.peer, plaintext); + this.apply(out.changes); + return { body: out.ciphertext, type: out.messageType }; + } + + async decryptWhisperMessage(ciphertext: Uint8Array) { + const out = await decryptWhisperWithSnapshot(this.snapshot, this.peer, ciphertext); + this.apply(out.changes); + return out.plaintext; + } + + async decryptPreKeyWhisperMessage(ciphertext: Uint8Array) { + const out = await decryptPreKeyWithSnapshot(this.snapshot, this.peer, ciphertext); + this.apply(out.changes); + return out.plaintext; + } +} + type WasmPair = { - alice: SessionCipher; - bob: SessionCipher; + alice: SnapshotCipher; + bob: SnapshotCipher; }; async function makeWasmPair(): Promise { @@ -112,27 +158,42 @@ async function makeWasmPair(): Promise { const bobAddr = new ProtocolAddress("bob", 1); const aliceAddr = new ProtocolAddress("alice", 1); - aliceStorage.trustIdentity("bob", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", aliceStorage.ourIdentityKeyPair.pubKey); - const sk = generateSignedPreKey(bobStorage.ourIdentityKeyPair, 1); const pk = generatePreKey(100); - bobStorage.storeSignedPreKey(sk.keyId, sk); - bobStorage.storePreKey(pk.keyId, pk.keyPair); - await new SessionBuilder(aliceStorage, bobAddr).processPreKeyBundle({ + const snapshotOf = (storage: FakeStorage, withKeys: boolean): SignalSnapshot => + ({ + identity: { + public: storage.ourIdentityKeyPair.pubKey, + private: storage.ourIdentityKeyPair.privKey, + }, + registrationId: storage.ourRegistrationId, + preKeys: withKeys ? [{ id: pk.keyId, keyPair: { public: pk.keyPair.pubKey, private: pk.keyPair.privKey } }] : [], + signedPreKeys: withKeys + ? [ + { + id: sk.keyId, + keyPair: { public: sk.keyPair.pubKey, private: sk.keyPair.privKey }, + signature: sk.signature, + }, + ] + : [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const opened = await processBundleWithSnapshot(snapshotOf(aliceStorage, false), bobAddr, { registrationId: bobStorage.ourRegistrationId, identityKey: bobStorage.ourIdentityKeyPair.pubKey, - signedPreKey: { - keyId: sk.keyId, - publicKey: sk.keyPair.pubKey, - signature: sk.signature, - }, + signedPreKey: { keyId: sk.keyId, publicKey: sk.keyPair.pubKey, signature: sk.signature }, preKey: { keyId: pk.keyId, publicKey: pk.keyPair.pubKey }, - }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); - const alice = new SessionCipher(aliceStorage, bobAddr); - const bob = new SessionCipher(bobStorage, aliceAddr); + const alice = new SnapshotCipher( + { ...snapshotOf(aliceStorage, false), session: opened.changes.session } as SignalSnapshot, + bobAddr, + ); + const bob = new SnapshotCipher(snapshotOf(bobStorage, true), aliceAddr); // PreKey handshake + reply so both sides have established sessions const first = await alice.encrypt(Buffer.from("hi")); From a6a0ca726d72227f97b011e1a1e8eb967a394282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas?= Date: Thu, 6 Aug 2026 19:10:19 -0300 Subject: [PATCH 71/71] fix(bridge): clear the bench session when the peer's identity is replaced `SnapshotCipher` models what the Baileys store does with a changeset, and it ignored `sessionCleared`. The core sets that when a peer's identity key is replaced: the ratchet built on the old key is void, so it returns no new session and asks the caller to drop the row instead. Applying only `changes.session` left the cipher holding bytes the core had disowned, and every later operation would have run on them. Not reachable from the benchmark as written, which never rotates an identity, but the point of this class is to mirror `applyChanges`, and it did not. The production path in `Signal/libsignal.ts` was already correct. --- packages/whatsapp-rust-bridge/benches/signal.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/whatsapp-rust-bridge/benches/signal.ts b/packages/whatsapp-rust-bridge/benches/signal.ts index c5f66a97f38..8ed94116e06 100644 --- a/packages/whatsapp-rust-bridge/benches/signal.ts +++ b/packages/whatsapp-rust-bridge/benches/signal.ts @@ -118,7 +118,17 @@ class SnapshotCipher { // eslint-disable-next-line @typescript-eslint/no-explicit-any private apply(changes: any) { - if (changes.session) this.snapshot = { ...this.snapshot, session: changes.session }; + // A replaced peer identity voids the ratchet built on the old one, and the + // core reports that by clearing rather than by handing back a new session. + // Keeping the old bytes would leave every later operation on a session the + // core has already disowned, which is what the Baileys store avoids by + // deleting the row. + if (changes.sessionCleared) { + this.snapshot = { ...this.snapshot, session: undefined }; + } else if (changes.session) { + this.snapshot = { ...this.snapshot, session: changes.session }; + } + if (changes.identity) this.snapshot = { ...this.snapshot, peerIdentity: changes.identity }; if (changes.removedPreKeyId !== undefined) { this.snapshot = {