diff --git a/.github/actions/setup-workspace/action.yml b/.github/actions/setup-workspace/action.yml new file mode 100644 index 00000000000..58501686dea --- /dev/null +++ b/.github/actions/setup-workspace/action.yml @@ -0,0 +1,162 @@ +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: + 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 + 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: + 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 + 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 + + # 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 + 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..cd191730399 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,21 +27,17 @@ on: - 'package.json' - 'pnpm-workspace.yaml' - '.github/workflows/bridge-build.yml' + - '.github/actions/setup-workspace/**' 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 +47,14 @@ 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 + # Compiles the crate and installs the workspace against it. + - name: Setup workspace + uses: ./.github/actions/setup-workspace 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- + # 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 @@ -107,19 +62,8 @@ 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 + - 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/.github/workflows/build.yml b/.github/workflows/build.yml index 586ca4c4259..e4b9095cf9c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,25 +6,22 @@ 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' + persist-credentials: false - - 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 diff --git a/AGENTS.md b/AGENTS.md index dc223d3282a..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 @@ -51,6 +51,12 @@ 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, on any platform, 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/baileys/package.json b/packages/baileys/package.json index 38b6054c689..3803314479a 100644 --- a/packages/baileys/package.json +++ b/packages/baileys/package.json @@ -43,7 +43,6 @@ "@cacheable/node-cache": "^1.4.0", "@hapi/boom": "^9.1.3", "async-mutex": "^0.5.0", - "libsignal": "^6.0.0", "lru-cache": "^11.1.0", "music-metadata": "^11.12.3", "p-queue": "^9.0.0", diff --git a/packages/baileys/src/Signal/Group/ciphertext-message.ts b/packages/baileys/src/Signal/Group/ciphertext-message.ts deleted file mode 100644 index 238e0151767..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/group-session-builder.ts b/packages/baileys/src/Signal/Group/group-session-builder.ts deleted file mode 100644 index b2a90b61e82..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/group_cipher.ts b/packages/baileys/src/Signal/Group/group_cipher.ts deleted file mode 100644 index 0f6c7f67ddc..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/index.ts b/packages/baileys/src/Signal/Group/index.ts deleted file mode 100644 index 52c983d7b10..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/keyhelper.ts b/packages/baileys/src/Signal/Group/keyhelper.ts deleted file mode 100644 index acf274c660c..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-chain-key.ts b/packages/baileys/src/Signal/Group/sender-chain-key.ts deleted file mode 100644 index 18d5cbf883b..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-key-distribution-message.ts b/packages/baileys/src/Signal/Group/sender-key-distribution-message.ts deleted file mode 100644 index 9888ae3895d..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-key-message.ts b/packages/baileys/src/Signal/Group/sender-key-message.ts deleted file mode 100644 index e6d8ac14058..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-key-name.ts b/packages/baileys/src/Signal/Group/sender-key-name.ts deleted file mode 100644 index 09486876b96..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-key-record.ts b/packages/baileys/src/Signal/Group/sender-key-record.ts deleted file mode 100644 index dda30c1eb16..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-key-state.ts b/packages/baileys/src/Signal/Group/sender-key-state.ts deleted file mode 100644 index 412972200c2..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/Group/sender-message-key.ts b/packages/baileys/src/Signal/Group/sender-message-key.ts deleted file mode 100644 index 7336a6e06d9..00000000000 --- a/packages/baileys/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/packages/baileys/src/Signal/legacy-session-codec.ts b/packages/baileys/src/Signal/legacy-session-codec.ts new file mode 100644 index 00000000000..9ea33fcbc94 --- /dev/null +++ b/packages/baileys/src/Signal/legacy-session-codec.ts @@ -0,0 +1,187 @@ +/** + * 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, recordRegistrationId?: number): LegacySessionV1 => { + const ratchet = entry.currentRatchet + const index = entry.indexInfo + 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, + 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 => { + // 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, record.registrationId) + })) + + 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/legacy-session.ts b/packages/baileys/src/Signal/legacy-session.ts new file mode 100644 index 00000000000..11c84c563bf --- /dev/null +++ b/packages/baileys/src/Signal/legacy-session.ts @@ -0,0 +1,91 @@ +/** + * 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 + /** + * 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`. */ +const OPEN = -1 + +export const isLegacySessionRecord = (value: unknown): value is LegacySessionRecord => + typeof value === 'object' && value !== null && !ArrayBuffer.isView(value) && '_sessions' in value + +/** 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 + * 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, record) && 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 40c2c8aeb70..b9a836a316a 100644 --- a/packages/baileys/src/Signal/libsignal.ts +++ b/packages/baileys/src/Signal/libsignal.ts @@ -1,17 +1,36 @@ -// @ts-ignore -import * as libsignal from 'libsignal' -// @ts-ignore -import { PreKeyWhisperMessage } from 'libsignal/src/protobufs' +import { Boom } from '@hapi/boom' import { LRUCache } from 'lru-cache' +import type { SignalChanges, SignalSnapshot, SignalStorage } from 'whatsapp-rust-bridge' +import { + decryptPreKeyWithSnapshot, + decryptWhisperWithSnapshot, + encryptWithSnapshot, + GroupCipher, + GroupSessionBuilder, + hasLogger, + importLegacySessionRecordV1, + processBundleWithSnapshot, + projectLegacySenderKeyRecordV1, + projectLegacySessionRecordV1, + ProtocolAddress, + SenderKeyDistributionMessage, + SenderKeyName, + SessionRecord, + setLogger +} from 'whatsapp-rust-bridge' +import { proto } from '../../WAProto/index.js' import type { LIDMapping, RecordRef, SignalAuthState, + SignalDataSet, + SignalDataTypeMap, SignalKeyStoreWithRecordTransaction, SignalKeyStoreWithTransaction } from '../Types' import type { SignalRepositoryWithLIDStore } from '../Types/Signal' -import { generateSignalPubKey } from '../Utils' +import { generateSignalPubKey } from '../Utils/crypto' +import { MISSING_KEYS_ERROR_TEXT } from '../Utils/decode-wa-message' import type { ILogger } from '../Utils/logger' import { isHostedLidUser, @@ -19,13 +38,12 @@ import { isLidUser, isPnUser, jidDecode, + jidEncode, 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 { hasOpenLegacySession, isLegacySessionEntry, isLegacySessionRecord, legacySessionInfo } from './legacy-session' +import { fromTypedRecord, toTypedRecord } from './legacy-session-codec' import { LIDMappingStore } from './lid-mapping' /** @@ -59,28 +77,75 @@ async function resolveSignalAddressId(id: string, lidMapping: LIDMappingStore): return id } -/** Extract identity key from PreKeyWhisperMessage for identity change detection */ -function extractIdentityFromPkmsg(ciphertext: Uint8Array): Uint8Array | undefined { - try { - if (!ciphertext || ciphertext.length < 2) { - return undefined - } +/** + * 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 + } - // Version byte check (version 3) - const version = ciphertext[0]! - if ((version & 0xf) !== 3) { - return undefined - } + return new Error(typeof error === 'string' ? error : String(error)) +} - // Parse protobuf (skip version byte) - const preKeyProto = PreKeyWhisperMessage.decode(ciphertext.slice(1)) - if (preKeyProto.identityKey?.length === 33) { - return new Uint8Array(preKeyProto.identityKey) - } +/** + * 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): 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 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 + +/** 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) { + return false + } + + if (isLegacySessionRecord(stored)) { + return hasOpenLegacySession(stored) + } + + try { + return SessionRecord.deserialize(stored).haveOpenSession() + } catch { + return false + } +} - return 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 { + const parsed = proto.PreKeySignalMessage.decode(ciphertext.slice(1)) + return typeof parsed.preKeyId === 'number' ? [parsed.preKeyId] : [] } catch { - return undefined + return [] } } @@ -89,55 +154,206 @@ 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) // 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, // 7 days + ttl: 3 * 24 * 60 * 60 * 1000, // 3 days ttlAutopurge: true, updateAgeOnGet: true }) 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(senderName, new SenderKeyRecord()) - } - + // 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 } } + /** + * 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()) + + // 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(wireJid, 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 + } + ) + } + + /** + * 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 (wireJid: string, stored: unknown): Promise => { + if (!stored) return undefined + + 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 + } 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 + } + } + + /** + * 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]: toStoredSession(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) - const cipher = new GroupCipher(storage, senderName) + const cipher = new GroupCipher(storage, group, jidToSignalProtocolAddress(authorJid)) return parsedKeys.transactWith({ records: [{ type: 'sender-key', id: senderName.toString() }] }, async () => { return cipher.decrypt(msg) @@ -149,14 +365,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 = new SenderKeyDistributionMessage( - null, - null, - null, - null, - item.axolotlSenderKeyDistributionMessage - ) + const senderMsg = SenderKeyDistributionMessage.deserialize(item.axolotlSenderKeyDistributionMessage) const senderNameStr = senderName.toString() // The "ensure a SenderKeyRecord exists" check runs INSIDE the @@ -168,84 +382,62 @@ 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(senderName, new SenderKeyRecord()) - } - - await builder.process(senderName, senderMsg) - }) + return parsedKeys.transactWith({ records: [{ type: 'sender-key', id: senderNameStr }] }, async () => + builder.process(senderName, senderMsg) + ) }, 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 libsignal.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: Buffer - switch (type) { - case 'pkmsg': - result = await session.decryptPreKeyWhisperMessage(ciphertext) - break - case 'msg': - result = await session.decryptWhisperMessage(ciphertext) - break - } - - return result - } + // 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 { + 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) + } - 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') + 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 libsignal.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: Buffer.from(body, 'binary') } + 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 + } }) }, @@ -253,7 +445,7 @@ export function makeLibSignalRepository( const senderName = jidToSignalSenderKeyName(group, meId) return parsedKeys.transactWith({ records: [{ type: 'sender-key', id: senderName.toString() }] }, async () => { const { skdm } = await ensureSenderKeyAndCreateSkdm(group, meId) - const ciphertext = await new GroupCipher(storage, senderName).encrypt(data) + const ciphertext = await new GroupCipher(storage, group, jidToSignalProtocolAddress(meId)).encrypt(data) return { ciphertext, senderKeyDistributionMessage: skdm.serialize() } }) }, @@ -274,38 +466,55 @@ export function makeLibSignalRepository( async getSessionInfo(jid) { const addr = jidToSignalProtocolAddress(jid).toString() - const session = (await storage.loadSession(addr)) as { - getOpenSession?: () => { indexInfo?: { baseKey?: Buffer }; registrationId?: number } | undefined - } | null - if (!session) { + const serialized = await storage.loadSession(addr) + if (!serialized) { return null } - const open = session.getOpenSession?.() - const baseKey = open?.indexInfo?.baseKey - const registrationId = open?.registrationId - if (!baseKey || typeof registrationId !== 'number') { - 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) } - return { baseKey: new Uint8Array(baseKey), registrationId } + // `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 + // wire `RecordStructure` protobuf, whose schema WAProto already carries. + try { + const { currentSession } = proto.RecordStructure.decode(serialized) + const baseKey = currentSession?.aliceBaseKey + const registrationId = currentSession?.remoteRegistrationId + if (!baseKey?.length || typeof registrationId !== 'number') { + return null + } + + return { baseKey: new Uint8Array(baseKey), registrationId } + } catch (error) { + logger.debug({ jid, error }, 'failed to decode session record for session info') + return null + } }, 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 libsignal.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() }, @@ -316,13 +525,19 @@ export function makeLibSignalRepository( async validateSession(jid: string) { try { const addr = jidToSignalProtocolAddress(jid) - const session = await storage.loadSession(addr.toString()) + const serialized = await storage.loadSession(addr.toString()) - if (!session) { + if (!serialized) { return { exists: false, reason: 'no session' } } - if (!session.haveOpenSession()) { + // `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' } } @@ -396,15 +611,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 foundByDevice = new Map() 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) @@ -413,10 +640,37 @@ export function makeLibSignalRepository( jid = `${user}:99@hosted` } - deviceJids.push(jid) + 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. 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: takeCandidate ? sessionKey : already.addrStr }, + 'device has a session under both the plain and hosted address; migrating one and leaving the other' + ) + + if (takeCandidate) { + 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( { fromJid, @@ -437,12 +691,18 @@ export function makeLibSignalRepository( pnUser: string lidUser: string deviceId: number - fromAddr: libsignal.ProtocolAddress - toAddr: libsignal.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)! @@ -452,14 +712,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() }) } @@ -472,21 +732,49 @@ 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. + 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 } = {} for (const op of migrationOps) { - const pnAddrStr = op.fromAddr.toString() + const pnAddrStr = op.fromAddrStr const lidAddrStr = op.toAddr.toString() 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)) { + // 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 + sessionUpdates[pnAddrStr] = null + + migratedCount++ + } + + continue + } + // Session exists (guaranteed from device discovery) - const fromSession = libsignal.SessionRecord.deserialize(pnSession) - if (fromSession.haveOpenSession()) { + const fromSession = SessionRecord.deserialize(pnSession) + // 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 @@ -520,7 +808,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 @@ -537,7 +825,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 => { @@ -558,11 +846,20 @@ function signalStorage( * any consult of `lidMapping`. */ pinnedResolver?: (id: string) => Promise -): SenderKeyStore & - libsignal.SignalStorage & { - loadIdentityKey(id: string): Promise - saveIdentity(id: string, identityKey: Uint8Array): 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)) return { @@ -570,17 +867,37 @@ function signalStorage( try { const wireJid = await resolveLIDSignalAddress(id) const { [wireJid]: sess } = await keys.get('session', [wireJid]) + if (!sess) { + return null + } - if (sess) { - return libsignal.SessionRecord.deserialize(sess) + // 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 — 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)) { + if (!hasOpenLegacySession(sess)) { + return null + } + + return importLegacySessionRecordV1(toTypedRecord(sess), { + identityKey: generateSignalPubKey(creds.signedIdentityKey.public), + registrationId: creds.registrationId + }) } + + return sess } catch (e) { return null } - - 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() } }) }, @@ -632,44 +949,50 @@ function signalStorage( } ) }, - 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 } } + + return null }, removePreKey: (id: number) => keys.set({ 'pre-key': { [id]: null } }), - loadSignedPreKey: () => { + loadSignedPreKey: async (id: number) => { const key = creds.signedPreKey + if (key?.keyId !== id) { + return null + } + return { - privKey: Buffer.from(key.keyPair.private), - pubKey: Buffer.from(key.keyPair.public) + 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]) - if (key) { - return SenderKeyRecord.deserialize(key) - } - - return new SenderKeyRecord() + return key ?? null }, - storeSenderKey: async (senderKeyName: SenderKeyName, key: SenderKeyRecord) => { - const keyId = senderKeyName.toString() - const serialized = JSON.stringify(key.serialize()) - await keys.set({ 'sender-key': { [keyId]: Buffer.from(serialized, 'utf-8') } }) + storeSenderKey: async (keyId: string, keyBytes: Uint8Array) => { + // 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: () => { const { signedIdentityKey } = creds return { - privKey: Buffer.from(signedIdentityKey.private), - pubKey: Buffer.from(generateSignalPubKey(signedIdentityKey.public)) + privKey: signedIdentityKey.private, + pubKey: signedIdentityKey.public } } } diff --git a/packages/baileys/src/Utils/auth-utils.ts b/packages/baileys/src/Utils/auth-utils.ts index 4e595993666..b42db5de9fc 100644 --- a/packages/baileys/src/Utils/auth-utils.ts +++ b/packages/baileys/src/Utils/auth-utils.ts @@ -1,7 +1,7 @@ -import NodeCache from '@cacheable/node-cache' import { Boom } from '@hapi/boom' import { AsyncLocalStorage } from 'async_hooks' import { randomBytes } from 'crypto' +import { LRUCache } from 'lru-cache' import { DEFAULT_CACHE_TTLS } from '../Defaults' import type { AuthenticationCreds, @@ -74,13 +74,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() + } const cacheLocks = makeLockManager() diff --git a/packages/baileys/src/Utils/crypto.ts b/packages/baileys/src/Utils/crypto.ts index 66c6c4e0950..2ec512ed8ce 100644 --- a/packages/baileys/src/Utils/crypto.ts +++ b/packages/baileys/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' import { KEY_BUNDLE_TYPE } from '../Defaults' import type { KeyPair } from '../Types' export { md5, hkdf } from 'whatsapp-rust-bridge' @@ -13,7 +13,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 @@ -21,15 +21,15 @@ 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 { // verifySignature returns false for a well-formed but wrong signature // and only throws on malformed input, so the result has to be returned. - return curve.verifySignature(generateSignalPubKey(pubKey), message, signature) + return verifySignature(generateSignalPubKey(pubKey), message, signature) } catch (error) { return false } diff --git a/packages/baileys/src/Utils/decode-wa-message.ts b/packages/baileys/src/Utils/decode-wa-message.ts index 39432e5eaac..32b94f3fb71 100644 --- a/packages/baileys/src/Utils/decode-wa-message.ts +++ b/packages/baileys/src/Utils/decode-wa-message.ts @@ -357,7 +357,7 @@ export const decryptMessageNode = ( } else { fullMessage.message = msg } - } catch (err: any) { + } catch (err: unknown) { const errorContext = { key: fullMessage.key, err, @@ -370,7 +370,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)] } } } @@ -391,3 +391,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 { message: unknown }).message) + } + + return String(error) +} diff --git a/packages/baileys/src/Utils/signal.ts b/packages/baileys/src/Utils/signal.ts index dfa1d97db89..14d17826ba1 100644 --- a/packages/baileys/src/Utils/signal.ts +++ b/packages/baileys/src/Utils/signal.ts @@ -204,8 +204,12 @@ export const extractDeviceJids = ( // Per device on purpose: one shared variable let a hosted device // leave the domain rewritten for every device listed after it. let domainType = userDomainType - if (isHosted) { - domainType = domainType === WAJIDDomains.LID ? WAJIDDomains.HOSTED_LID : WAJIDDomains.HOSTED + // 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) { + 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/Group/sender-key-state-regression.test.ts b/packages/baileys/src/__tests__/Signal/Group/sender-key-state-regression.test.ts deleted file mode 100644 index 9c49f62613a..00000000000 --- a/packages/baileys/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/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..96102368ed2 --- /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 { Curve, 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 = Curve.generateKeyPair() + 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/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/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts new file mode 100644 index 00000000000..6d19cecfd68 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/duplicate-decrypt.test.ts @@ -0,0 +1,126 @@ +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' +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 = Curve.generateKeyPair() + 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. 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 + ) + }) + + 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/group-sender-key.test.ts b/packages/baileys/src/__tests__/Signal/group-sender-key.test.ts new file mode 100644 index 00000000000..fe4b2815f8d --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/group-sender-key.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 } 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 + * 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('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()) + 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, + 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('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() + + 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/identity-format.test.ts b/packages/baileys/src/__tests__/Signal/identity-format.test.ts new file mode 100644 index 00000000000..a28f79950cb --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/identity-format.test.ts @@ -0,0 +1,72 @@ +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' + +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 = Curve.generateKeyPair() + await bob.auth.keys.set({ 'pre-key': { 1: pk } }) + await alice.repository.injectE2ESession({ + 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: { + 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/legacy-codec.test.ts b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts new file mode 100644 index 00000000000..8567e6bb86c --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/legacy-codec.test.ts @@ -0,0 +1,205 @@ +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' +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') { + // 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) + // 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)) + }) +}) + +/** + * 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) + }) +}) 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 new file mode 100644 index 00000000000..fbe71d974c7 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/legacy-session.test.ts @@ -0,0 +1,389 @@ +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' +import { generateSignalPubKey } from '../../Utils/crypto' +import { WAJIDDomains } from '../../WABinary' + +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' + + // 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() } }) + + 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 () => { + 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 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() + }) + + /** + * 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({ + 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 + } + } + }) + + return Object.values(data.session!)[0] + } + + 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(sessionIdentity(data.session![lidAddr])).toEqual(sessionIdentity(newer)) + 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(sessionIdentity(data.session![`18000000000004_${WAJIDDomains.HOSTED_LID}.99`])).toEqual( + sessionIdentity(pnBytes) + ) + 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(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. + 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(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. + expect(data.session![hostedAddr]).toBeDefined() + }) + + 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__/Signal/libsignal.test.ts b/packages/baileys/src/__tests__/Signal/libsignal.test.ts new file mode 100644 index 00000000000..0f54810fd73 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/libsignal.test.ts @@ -0,0 +1,100 @@ +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: () => Promise) => await work(), + isInTransaction: () => false + } + // 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) + + 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/packages/baileys/src/__tests__/Signal/rollback.test.ts b/packages/baileys/src/__tests__/Signal/rollback.test.ts new file mode 100644 index 00000000000..6b49e6047ee --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/rollback.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +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' +import { addTransactionCapability } from '../../Utils/auth-utils' +import fixture from '../fixtures/legacy-session-rc9.json' + +/** + * 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' }) + +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('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. + 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') }) + + // 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('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 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(stored)) + }) + + it('leaves a group sender key in the shape the old build reads', async () => { + const bob = makeBob() + + 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`] + expect(ArrayBuffer.isView(stored)).toBe(true) + + // 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() + }) +}) diff --git a/packages/baileys/src/__tests__/Signal/session-info.test.ts b/packages/baileys/src/__tests__/Signal/session-info.test.ts new file mode 100644 index 00000000000..43e36be5a75 --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/session-info.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from '@jest/globals' +import P from 'pino' +import { generatePreKey, generateSignedPreKey } from 'whatsapp-rust-bridge' +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 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 makeAuthState = (): SignalAuthState => ({ + creds: initAuthCreds(), + keys: addTransactionCapability(makeMemoryKeyStore(), logger, { + maxCommitRetries: 1, + delayBetweenTriesMs: 1 + }) +}) + +/** + * `getSessionInfo` decodes the persisted record with WAProto's `RecordStructure` + * because the WASM bridge exposes no accessor for the open state's fields. That + * only holds while the bridge keeps serializing sessions as that same protobuf, + * so drive a real session through the bridge and assert the fields come back — + * a format change on the Rust side has to fail here rather than silently turn + * `getSessionInfo` into a `null`-returning no-op. + */ +describe('getSessionInfo', () => { + const remoteJid = '5511999887766@s.whatsapp.net' + + const establishSession = async () => { + const auth = makeAuthState() + const repository = makeLibSignalRepository(auth, logger) + + const peer = initAuthCreds() + const peerPreKey = generatePreKey(1) + const peerSignedPreKey = generateSignedPreKey( + { pubKey: peer.signedIdentityKey.public, privKey: peer.signedIdentityKey.private }, + 1 + ) + + await repository.injectE2ESession({ + jid: remoteJid, + session: { + registrationId: peer.registrationId, + identityKey: generateSignalPubKey(peer.signedIdentityKey.public), + preKey: { keyId: 1, publicKey: peerPreKey.keyPair.pubKey }, + signedPreKey: { + keyId: 1, + publicKey: peerSignedPreKey.keyPair.pubKey, + signature: peerSignedPreKey.signature + } + } as never + }) + + return { repository, peer } + } + + it('returns the base key and registration id of an established session', async () => { + const { repository, peer } = await establishSession() + + const info = await repository.getSessionInfo(remoteJid) + + expect(info).not.toBeNull() + expect(info!.registrationId).toBe(peer.registrationId) + expect(info!.baseKey.length).toBeGreaterThan(0) + }) + + it('keeps the base key stable across encrypts on the same session', async () => { + const { repository } = await establishSession() + + const first = await repository.getSessionInfo(remoteJid) + await repository.encryptMessage({ jid: remoteJid, data: Buffer.from('hello') }) + const second = await repository.getSessionInfo(remoteJid) + + expect(second).not.toBeNull() + expect(Buffer.from(second!.baseKey)).toEqual(Buffer.from(first!.baseKey)) + }) + + it('returns null when no session exists', async () => { + const auth = makeAuthState() + const repository = makeLibSignalRepository(auth, logger) + + await expect(repository.getSessionInfo(remoteJid)).resolves.toBeNull() + }) +}) 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..ddaf0b5baa5 --- /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 { Curve, 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 = Curve.generateKeyPair() + 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__/Signal/snapshot-session.test.ts b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts new file mode 100644 index 00000000000..3b9e5c9fead --- /dev/null +++ b/packages/baileys/src/__tests__/Signal/snapshot-session.test.ts @@ -0,0 +1,396 @@ +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, + SignalDataSet, + SignalDataTypeMap, + SignalKeyStore, + SignalKeyStoreWithRecordTransaction +} from '../../Types' +import { addTransactionCapability, initAuthCreds } from '../../Utils/auth-utils' +import { Curve, 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' }) + +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 = () => { + 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 = Curve.generateKeyPair() + 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) + // 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. + 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('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() + 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('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('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') }) + + 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 () => { + // 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() + 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('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' + + // 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 }] }, () => release.promise) + .then(() => order.push('lock released')) + + 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()]) + release.resolve() + await Promise.all([holding, encrypting]) + + expect(order).toEqual(['lock released', 'encrypt finished']) + }) + + 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' + ]) + }) +}) diff --git a/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts new file mode 100644 index 00000000000..29b7a7c28ec --- /dev/null +++ b/packages/baileys/src/__tests__/Utils/signal-hosted.test.ts @@ -0,0 +1,152 @@ +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' + 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: 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]!.device).toBe(99) + // Must be HOSTED (128), 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: 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]!.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: USyncQueryResultList[] = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 33, keyIndex: 1, isHosted: true }] + } + } + ] + + const result = extractDeviceJids(mockResult, 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: USyncQueryResultList[] = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 33, keyIndex: 1, isHosted: false }] + } + } + ] + + const result = extractDeviceJids(mockResult, 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: USyncQueryResultList[] = [ + { + id: targetUser, + devices: { + deviceList: [{ id: 99, keyIndex: 1, isHosted: true }] + } + } + ] + + 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 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[] = [ + { + 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') + }) +}) 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..4fe697c8402 --- /dev/null +++ b/packages/baileys/src/__tests__/fixtures/legacy-session-rc9.json @@ -0,0 +1 @@ +{"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/Cargo.lock b/packages/whatsapp-rust-bridge/Cargo.lock index ab76dff3874..65741896b45 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,15 @@ 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 = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "castaway" @@ -141,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" @@ -155,7 +232,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -186,17 +263,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 +355,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 +410,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 +474,18 @@ 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 = "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" @@ -394,7 +494,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -447,7 +546,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core 0.10.1", + "rand_core", "wasip2", "wasip3", "wasm-bindgen", @@ -469,6 +568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", + "serde", ] [[package]] @@ -477,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", -] - [[package]] name = "heck" version = "0.5.0" @@ -611,15 +699,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 +735,18 @@ 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -678,6 +769,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" @@ -698,6 +799,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" @@ -705,6 +815,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -713,6 +824,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" @@ -743,6 +860,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 +873,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -762,29 +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", - "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]] name = "pxfm" version = "0.1.29" @@ -820,15 +920,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" @@ -844,6 +938,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -851,10 +958,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] -name = "ryu" -version = "1.0.23" +name = "same-file" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] [[package]] name = "semver" @@ -919,7 +1029,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -930,7 +1040,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -979,18 +1089,48 @@ 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" 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 +1258,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 +1277,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 +1334,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -1220,15 +1384,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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "anyhow", - "bytemuck", + "buffa", "hex", "hkdf 0.13.0", + "hmac 0.13.0", "log", - "prost", "serde", "serde-big-array", "serde_json", @@ -1241,62 +1405,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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" 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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" +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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" 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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ "anyhow", + "buffa", "bytes", "hkdf 0.13.0", "log", - "prost", "rand", "sha2 0.11.0", "thiserror", @@ -1305,13 +1479,28 @@ 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.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#81c26b3cb08d22750e79d4b1a3e9f45fbd3f355f" dependencies = [ - "prost", + "buffa", + "buffa-build", + "buffa-descriptor", + "bytes", + "heck", "serde", + "sha2 0.11.0", ] [[package]] @@ -1374,7 +1563,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -1387,6 +1576,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" @@ -1437,24 +1665,21 @@ version = "0.1.0" dependencies = [ "async-trait", "base64", - "curve25519-dalek", + "buffa", + "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", @@ -1465,9 +1690,34 @@ 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" +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 +1754,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -1520,7 +1770,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -1564,13 +1814,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 +1842,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1614,7 +1863,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -1623,26 +1872,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..445adb944de 100644 --- a/packages/whatsapp-rust-bridge/Cargo.toml +++ b/packages/whatsapp-rust-bridge/Cargo.toml @@ -44,65 +44,82 @@ 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", ] } +# 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", - "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 } +# 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", - "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", -] } 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", ] } +# 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" + +# Keeps the name section and debug info so a CPU profile shows real symbols +# 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 +strip = false +lto = false + [profile.release] lto = "fat" opt-level = 3 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..8ed94116e06 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,62 @@ 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) { + // 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 = { + ...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 +168,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")); @@ -220,12 +291,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 +320,7 @@ boxplot(() => { do_not_optimize(result); }, }; - }).gc("inner"); + }); bench("Decrypt WhisperMessage (libsignal-node)", function* () { yield { @@ -261,7 +332,7 @@ boxplot(() => { do_not_optimize(result); }, }; - }).gc("inner"); + }); }); }); @@ -282,7 +353,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 +366,7 @@ boxplot(() => { ); do_not_optimize(decryptedByBob); do_not_optimize(decryptedByAlice); - }).gc("inner"); + }); }); }); diff --git a/packages/whatsapp-rust-bridge/package.json b/packages/whatsapp-rust-bridge/package.json index 1ea7fba81ee..3fb613cc32d 100644 --- a/packages/whatsapp-rust-bridge/package.json +++ b/packages/whatsapp-rust-bridge/package.json @@ -40,11 +40,14 @@ "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", "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:if-available && pnpm run test:jest && pnpm run test:package", "prepublishOnly": "pnpm run build" }, "devDependencies": { diff --git a/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs b/packages/whatsapp-rust-bridge/scripts/build-wasm.mjs index 5ea9aa69d74..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, readFileSync, statSync, writeFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { delimiter, dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const __dirname = dirname(fileURLToPath(import.meta.url)) @@ -47,20 +47,96 @@ 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 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 (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 + + for (const extension of extensions) { + const candidate = resolve(directory, `wasm-bindgen${extension}`) + if (matches(candidate)) return candidate + } + } + + // 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} 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.' + ) +} + 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)`) 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) 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..41bf0584056 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/counter_lease.rs @@ -0,0 +1,39 @@ +//! 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. +//! +//! 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}; + +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/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/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/legacy_session.rs b/packages/whatsapp-rust-bridge/src/legacy_session.rs new file mode 100644 index 00000000000..e48ce654146 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/legacy_session.rs @@ -0,0 +1,493 @@ +//! Typed boundary for the decoded libsignal `SessionRecord` v1 model. + +use bytes::Bytes; +use js_sys::Uint8Array; +use serde::{Deserialize, Serialize}; +use tsify::Tsify; +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, +}; +use wasm_bindgen::prelude::*; + +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}")))?; + let record = CoreRecord::try_from(record) + .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}")))?; + 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}")))?; + 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}"))), + }, + } +} + +/// 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/lib.rs b/packages/whatsapp-rust-bridge/src/lib.rs index de0f435f55d..81108b28a21 100644 --- a/packages/whatsapp-rust-bridge/src/lib.rs +++ b/packages/whatsapp-rust-bridge/src/lib.rs @@ -2,20 +2,23 @@ pub mod appstate; #[cfg(feature = "audio")] pub mod audio; 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")] pub mod image_utils; pub mod key_helper; +pub mod legacy_session; 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; #[cfg(feature = "sticker")] pub mod sticker_metadata; pub mod storage_adapter; 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_builder.rs b/packages/whatsapp-rust-bridge/src/session_builder.rs deleted file mode 100644 index 94861ca6d82..00000000000 --- a/packages/whatsapp-rust-bridge/src/session_builder.rs +++ /dev/null @@ -1,129 +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 { - 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 2e49ac0e165..00000000000 --- a/packages/whatsapp-rust-bridge/src/session_cipher.rs +++ /dev/null @@ -1,148 +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, 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) - })?; - - Ok(bytes_to_uint8array(&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)) - } - - #[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/snapshot_api.rs b/packages/whatsapp-rust-bridge/src/snapshot_api.rs new file mode 100644 index 00000000000..3f590be40e7 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/snapshot_api.rs @@ -0,0 +1,405 @@ +//! 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 _, 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; +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) + .map_err(|e| err("snapshot.senderKey", e))?; + } + + 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. 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(); + 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), + 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..ee2f54b6236 --- /dev/null +++ b/packages/whatsapp-rust-bridge/src/snapshot_store.rs @@ -0,0 +1,398 @@ +//! 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(crate::counter_lease::waive_session(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) -> SignalResult<()> { + 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(()) + } + + 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<()> { + // 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); + 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()); + + // 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 => { + // 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<()> { + // 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<()> { + 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<()> { + // 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(), + )) + } +} + +#[async_trait(?Send)] +impl SenderKeyStore for SnapshotStore { + async fn store_sender_key( + &mut self, + _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); + inner.sender_key = Some(record); + Ok(()) + } + + async fn load_sender_key( + &self, + _sender_key_name: &CoreSenderKeyName, + ) -> SignalResult> { + 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 6136b8108a7..c7dda34f5cf 100644 --- a/packages/whatsapp-rust-bridge/src/storage_adapter.rs +++ b/packages/whatsapp-rust-bridge/src/storage_adapter.rs @@ -1,53 +1,29 @@ use async_trait::async_trait; use base64::prelude::*; +use buffa::{Message as _, MessageField}; 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 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; -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, -}; +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; } @@ -59,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; @@ -123,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>>, } @@ -137,82 +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 { - let name = address.name(); - let cache = self.last_address_cache.borrow(); - if let Some((cached_name, cached_str)) = cache.as_ref() - && cached_name == name - { - return cached_str.clone(); - } - drop(cache); - - let addr_str = address.to_string(); - self.last_address_cache - .borrow_mut() - .replace((name.to_string(), 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(); @@ -236,227 +85,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 = 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()), - }); - } - - sender_chain_struct = Some(Chain { - sender_ratchet_key: Some(pub_key), - sender_ratchet_key_private: Some(priv_key), - chain_key: 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()), - }); - } - - receiver_chains_vec.push(Chain { - sender_ratchet_key: Some(sender_ratchet), - sender_ratchet_key_private: None, - chain_key: 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: None, - pending_pre_key: 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: 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, @@ -484,14 +112,21 @@ 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(); - let private_key = get_bytes_from_buffer_json(&sender_signing_key_obj, "private")?; + 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 + // 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)) @@ -502,7 +137,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), @@ -522,152 +157,24 @@ 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, }); } + // 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())) } } -#[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)) @@ -681,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) @@ -725,25 +212,13 @@ 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()) -} - 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 { @@ -752,6 +227,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); @@ -760,14 +242,19 @@ fn get_bytes_from_buffer_json(obj: &JsValue, key: &str) -> SignalResult 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); - } - - 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 { - Some(data) => { - let record = 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); - - 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 = address.name().to_string(); - 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) - } - - async fn save_identity( - &mut self, - address: &libsignal::ProtocolAddress, - identity: &libsignal::IdentityKey, - ) -> SignalResult { - let address_name = address.name().to_string(); - 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 = address.name().to_string(); - 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( @@ -1108,6 +318,10 @@ 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() .insert(key_id, record.clone()); @@ -1121,6 +335,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()); @@ -1137,3 +353,280 @@ impl SenderKeyStore for JsStorageAdapter { Ok(()) } } + +/// 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>, + /// 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)] + #[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()?; + // 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 { + iteration: state.chain_key.iteration, + seed: bytes(&state.chain_key.seed), + }, + sender_signing_key: SigningKey { + public: bytes(&state.signing_key.public), + private: bytes(state.signing_key.private.as_deref().unwrap_or(&[])), + }, + 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}; + 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 + ); + } + + #[test] + fn a_buffer_json_envelope_with_base64_data_decodes_to_bytes() { + // Baileys' own BufferJSON.replacer writes the payload as base64 text + // rather than a byte array, so a store that round-trips through it + // hands us this shape. Reading it as absent would silently default the + // field to empty and leave the record unusable. + 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"), &"AQID".into()).unwrap(); + let holder = object_with("seed", envelope.into()); + + assert_eq!( + get_bytes_from_buffer_json(&holder, "seed").unwrap(), + Some(vec![1, 2, 3]) + ); + } + + #[test] + fn a_buffer_json_envelope_with_unreadable_data_is_absent() { + 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"), + &JsValue::from_f64(7.0), + ) + .unwrap(); + let holder = object_with("seed", envelope.into()); + + assert_eq!(get_bytes_from_buffer_json(&holder, "seed").unwrap(), None); + } +} + +#[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), + ) + .expect("valid state"); + 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" + ); + } + + /// 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}" + ); + } +} diff --git a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts index a26cd1d351d..4e2f3b02c6a 100644 --- a/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts +++ b/packages/whatsapp-rust-bridge/test/crypto-parity.test.ts @@ -1,63 +1,79 @@ 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 nodeMd5 = (data: Uint8Array) => + createHash("md5").update(data).digest(); + +const nodeHkdf = 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"); } -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 () => { @@ -66,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/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..bceb51bcef9 --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire-vectors.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 00000000000..6376ba9ff26 --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/helpers/legacy-wire.ts @@ -0,0 +1,105 @@ +/** + * 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 -- --runInBand`, commit the JSON, + * then drop the dep again. + */ +import { createRequire } from "node:module"; +import { existsSync, readFileSync, renameSync, 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; +/** Recording must run single-threaded (`--runInBand`); this merge is not atomic. */ +const persist = () => { + const onDisk = existsSync(store) + ? JSON.parse(readFileSync(store, "utf8")) + : { encoded: {}, 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. + * + * 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 => { + 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)])); + } + 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)) as Node; +} 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); - }); -}); 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/sender_key_migration.test.ts b/packages/whatsapp-rust-bridge/test/sender_key_migration.test.ts index 3d09f4ac450..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,13 +3,21 @@ 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 () => { + /** + * The JS libsignal omitted senderMessageKeys entirely when a state had none, + * so a real upgraded auth state carries rows in this shape. + */ + const shapes = [ + ["present but empty", { senderMessageKeys: [] }], + ["omitted", {}], + ["null", { senderMessageKeys: null }], + ] 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); - // Legacy JSON structure (as provided by user) - // It's an array of SenderKeyStateStructure const legacySenderKey = [ { senderKeyId: 12345, @@ -21,25 +29,18 @@ describe("Legacy SenderKey Migration", () => { public: { type: "Buffer", data: Array.from(Buffer.alloc(32, 2)) }, private: { type: "Buffer", data: Array.from(Buffer.alloc(32, 3)) }, }, - senderMessageKeys: [], + ...extra, }, ]; - 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 + Buffer.from(JSON.stringify(legacySenderKey), "utf-8") ); const cipher = new GroupCipher(storage, groupId, sender); + const ciphertext = await cipher.encrypt(new Uint8Array([1, 2, 3])); - const plaintext = new Uint8Array([1, 2, 3]); - const ciphertext = await cipher.encrypt(plaintext); - - expect(ciphertext).toBeDefined(); expect(ciphertext.length).toBeGreaterThan(0); }); }); 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 { 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 f0bd571f098..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")).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")).toEqual( - first.identityKeyPair.pubKey - ); - - const second = makeBundle(); - await new SessionBuilder(storage, bobAddress).processPreKeyBundle( - second.bundle - ); - - expect(storage.getIdentity("bob-persisted")).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", 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 947da6373ee..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", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", 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", bobStorage.ourIdentityKeyPair.pubKey); - bobStorage.trustIdentity("alice", 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 new file mode 100644 index 00000000000..aff7962e34c --- /dev/null +++ b/packages/whatsapp-rust-bridge/test/snapshot_api.test.ts @@ -0,0 +1,376 @@ +import { describe, it, expect } from "@jest/globals"; +import { + processBundleWithSnapshot, + ProtocolAddress, + 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 (setup only). */ +async function establish(alice: Party, bob: Party, bobAddr: ProtocolAddress) { + // The same call the consumer makes. + const out = await processBundleWithSnapshot(snapshotOf(alice), 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); + + const session = out.changes.session; + 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); + }); + + // 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(); + 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(); + }); +}); diff --git a/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts b/packages/whatsapp-rust-bridge/test/storage_adapter.test.ts index 7262e8ef295..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,24 +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); - expect(storage.getIdentity("alice")).toBeUndefined(); - - storage.failIdentityStore = false; - storage.failSessionStore = false; - await builder.processPreKeyBundle(bundle); - - expect(storage.identityLoadCount).toBe(2); - expect(storage.getIdentity("alice")).toEqual(bundle.identityKey); - }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5078f6eae3d..dca3499eca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,9 +51,6 @@ importers: audio-decode: specifier: ^2.1.3 version: 2.2.3 - libsignal: - specifier: ^6.0.0 - version: 6.0.0 lru-cache: specifier: ^11.1.0 version: 11.3.6 @@ -2842,9 +2839,6 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} - libsignal@6.0.0: - resolution: {integrity: sha512-d/5V3YFtDljbFMufz4ncyUYGYhJl+vzAe+c2EFFBQ6bz1h8Q3IOMEGXYMzlibU60I+e8GagMMpji18iez3P1hA==} - libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7} version: 6.0.0 @@ -7069,11 +7063,6 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 - libsignal@6.0.0: - dependencies: - curve25519-js: 0.0.4 - protobufjs: 7.5.6 - libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7: dependencies: curve25519-js: 0.0.4