diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af7a8d..4998f86 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -193,6 +193,29 @@ jobs: - name: run sqlite tests run: npm test -w packages/store-sqlite + test-migrate: + runs-on: ubuntu-latest + needs: test-core + + steps: + - name: checkout repository + uses: actions/checkout@v4 + + - name: setup node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: install dependencies + run: npm ci + + - name: build + run: npm run build && npx turbo run build --filter=@zapo-js/store-migrate + + - name: run migrate tests + run: npm test -w packages/store-migrate + test-mysql: runs-on: ubuntu-latest needs: test-core diff --git a/eslint.config.js b/eslint.config.js index 52bda66..d0cfe91 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -46,6 +46,7 @@ module.exports = [ './packages/store-postgres/tsconfig.json', './packages/store-redis/tsconfig.json', './packages/store-mongo/tsconfig.json', + './packages/store-migrate/tsconfig.json', './packages/media-utils/tsconfig.json', './packages/fake-server/tsconfig.json' ] @@ -79,6 +80,7 @@ module.exports = [ './packages/store-postgres/tsconfig.json', './packages/store-redis/tsconfig.json', './packages/store-mongo/tsconfig.json', + './packages/store-migrate/tsconfig.json', './packages/media-utils/tsconfig.json', './packages/fake-server/tsconfig.json' ] diff --git a/package-lock.json b/package-lock.json index 92c16ed..5f57dcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "url": "https://github.com/sponsors/vinikjkkj" }, "peerDependencies": { - "argo-codec": "^0.2.0", + "argo-codec": "^0.2.1", "pino": "^9.0.0", "pino-pretty": "^13.0.0", "ws": "^8.18.3" @@ -2454,6 +2454,10 @@ "resolved": "packages/media-utils", "link": true }, + "node_modules/@zapo-js/store-migrate": { + "resolved": "packages/store-migrate", + "link": true + }, "node_modules/@zapo-js/store-mongo": { "resolved": "packages/store-mongo", "link": true @@ -8329,6 +8333,19 @@ "zapo-js": ">=0.1.0" } }, + "packages/store-migrate": { + "name": "@zapo-js/store-migrate", + "version": "0.1.0", + "devDependencies": { + "zapo-js": "file:../.." + }, + "engines": { + "node": ">=20.9.0" + }, + "peerDependencies": { + "zapo-js": ">=0.1.0" + } + }, "packages/store-mongo": { "name": "@zapo-js/store-mongo", "version": "0.2.0", diff --git a/packages/store-migrate/package.json b/packages/store-migrate/package.json new file mode 100644 index 0000000..e0f37a2 --- /dev/null +++ b/packages/store-migrate/package.json @@ -0,0 +1,44 @@ +{ + "name": "@zapo-js/store-migrate", + "version": "0.1.0", + "description": "Pure converters for migrating Baileys and whatsmeow stores into zapo-js", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "require": "./dist/index.js", + "default": "./dist/index.js" + }, + "./baileys": { + "types": "./dist/baileys/index.d.ts", + "require": "./dist/baileys/index.js", + "default": "./dist/baileys/index.js" + }, + "./whatsmeow": { + "types": "./dist/whatsmeow/index.d.ts", + "require": "./dist/whatsmeow/index.js", + "default": "./dist/whatsmeow/index.js" + } + }, + "files": [ + "dist" + ], + "publishConfig": { + "access": "public" + }, + "scripts": { + "build": "tsc -p tsconfig.build.cjs.json", + "typecheck": "tsc --noEmit", + "test": "node --import tsx --test \"src/**/__tests__/**/*.test.ts\"" + }, + "peerDependencies": { + "zapo-js": ">=0.1.0" + }, + "devDependencies": { + "zapo-js": "file:../.." + }, + "engines": { + "node": ">=20.9.0" + } +} diff --git a/packages/store-migrate/src/__tests__/address.test.ts b/packages/store-migrate/src/__tests__/address.test.ts new file mode 100644 index 0000000..955e9b8 --- /dev/null +++ b/packages/store-migrate/src/__tests__/address.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { parseLibsignalAddressString, signalAddressFromLibsignalString } from '../util/address' + +describe('parseLibsignalAddressString', () => { + it('parses Baileys-style dotted address', () => { + assert.deepEqual(parseLibsignalAddressString('5511999999999.0'), { + id: '5511999999999', + device: 0 + }) + }) + + it('parses whatsmeow-style colon address', () => { + assert.deepEqual(parseLibsignalAddressString('5511999999999:7'), { + id: '5511999999999', + device: 7 + }) + }) + + it('uses the rightmost separator so dotted ids survive', () => { + // Theoretical case — IDs typically don't contain dots, but the parser + // must split at the trailing device delimiter regardless. + assert.deepEqual(parseLibsignalAddressString('1.2.3:42'), { + id: '1.2.3', + device: 42 + }) + }) + + it('rejects malformed input', () => { + assert.throws(() => parseLibsignalAddressString('5511')) + assert.throws(() => parseLibsignalAddressString('.5')) + assert.throws(() => parseLibsignalAddressString('5511.')) + assert.throws(() => parseLibsignalAddressString('5511.abc')) + }) +}) + +describe('signalAddressFromLibsignalString', () => { + it('defaults to s.whatsapp.net when no domain marker is present', () => { + const addr = signalAddressFromLibsignalString('5511999999999.0') + assert.equal(addr.user, '5511999999999') + assert.equal(addr.server, 's.whatsapp.net') + assert.equal(addr.device, 0) + }) + + it('strips Baileys lid suffix and switches server to lid', () => { + const addr = signalAddressFromLibsignalString('102513101521058_1.12') + assert.equal(addr.user, '102513101521058') + assert.equal(addr.server, 'lid') + assert.equal(addr.device, 12) + }) + + it('honours explicit server override', () => { + const addr = signalAddressFromLibsignalString('5511999999999.3', { server: 'lid' }) + assert.equal(addr.server, 'lid') + }) + + it('does not strip non-numeric underscore suffixes', () => { + const addr = signalAddressFromLibsignalString('user_name.0') + assert.equal(addr.user, 'user_name') + assert.equal(addr.server, 's.whatsapp.net') + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-appstate.test.ts b/packages/store-migrate/src/__tests__/baileys-appstate.test.ts new file mode 100644 index 0000000..86d9f82 --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-appstate.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { bytesToHex } from 'zapo-js/util' + +import { convertBaileysAppStateSyncKey, convertBaileysAppStateVersion } from '../baileys/appstate' + +describe('convertBaileysAppStateSyncKey', () => { + it('decodes base64 keyId strings and coerces string timestamps', () => { + const result = convertBaileysAppStateSyncKey('AAECAwQ=', { + keyData: new Uint8Array([10, 20, 30]), + timestamp: '1700000000', + fingerprint: { rawId: 7, currentIndex: 1, deviceIndexes: [0, 12] } + }) + assert.deepEqual(Array.from(result.keyId), [0, 1, 2, 3, 4]) + assert.deepEqual(Array.from(result.keyData), [10, 20, 30]) + assert.equal(result.timestamp, 1700000000) + assert.equal(result.fingerprint?.rawId, 7) + assert.deepEqual(result.fingerprint?.deviceIndexes, [0, 12]) + }) + + it('accepts raw Uint8Array keyId', () => { + const id = new Uint8Array([99]) + const result = convertBaileysAppStateSyncKey(id, { + keyData: new Uint8Array([1]), + timestamp: 42 + }) + assert.equal(result.keyId, id) + assert.equal(result.timestamp, 42) + assert.equal(result.fingerprint, undefined) + }) +}) + +describe('convertBaileysAppStateVersion', () => { + it('rekeys indexValueMap from base64(indexMac) to hex', () => { + const indexMac = new Uint8Array([0xab, 0xcd, 0xef]) + const valueMac = new Uint8Array([1, 2, 3]) + const indexB64 = Buffer.from(indexMac).toString('base64') + + const result = convertBaileysAppStateVersion('regular', { + version: 5, + hash: new Uint8Array(128), + indexValueMap: { + [indexB64]: { valueMac } + } + }) + + assert.equal(result.collection, 'regular') + assert.equal(result.version, 5) + assert.equal(result.hash.length, 128) + const hexKey = bytesToHex(indexMac) + assert.equal(result.indexValueMap.get(hexKey), valueMac) + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-creds.test.ts b/packages/store-migrate/src/__tests__/baileys-creds.test.ts new file mode 100644 index 0000000..3b53283 --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-creds.test.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { proto } from 'zapo-js/proto' + +import { convertBaileysCreds } from '../baileys/creds' +import type { BaileysAuthenticationCreds } from '../baileys/types' +import { bufferJsonReviver } from '../util/buffer-json' + +// Fixture sourced from the upstream Baileys repo (`baileys_auth_info/creds.json`). +// It is a test/staging account left in the public repo, not user data. +const FIXTURE_TEXT = JSON.stringify({ + noiseKey: { + private: { type: 'Buffer', data: '2P6qB8qA7LGQ+YKENJk+ZPLeIiCUE7Ars25d8Lnr7UQ=' }, + public: { type: 'Buffer', data: 'DLdrevDQe6DysKKSUl/3IdwYUF2NzAGSyIEpCn/2CRU=' } + }, + pairingEphemeralKeyPair: { + private: { type: 'Buffer', data: '4BV6WbXF+gw9AtTmeBEyOX8XGfhjSkZvhF2vfOPT80A=' }, + public: { type: 'Buffer', data: 'FF+bnNQwMIlau1LWKYhXMuW69B3hmREeOLD0sxwT9Ek=' } + }, + signedIdentityKey: { + private: { type: 'Buffer', data: 'iLPyTWgfLBHuLhJDtahbr6b+xo/VscMkPrxNitQtU3U=' }, + public: { type: 'Buffer', data: 'f6MzdnoPtgnaoOz4G7GIKdWNuqjI1W/npxwRDC3T7D4=' } + }, + signedPreKey: { + keyPair: { + private: { type: 'Buffer', data: 'sP1nJcLT26EhLlqZ8ieH10jj6+YVZ7O4zbwX6EabaX8=' }, + public: { type: 'Buffer', data: 'qF+uRgG/gfuIPAQHTgkPwu/99fBbqPR8fvGfgYIZolk=' } + }, + signature: { + type: 'Buffer', + data: 'vxiRzFrRl/7gW5PtqiyfUkDqqPuvSbL511Ay3oVryE8/u/uW6anGIaGbP68zLKxFQbzVPOlGw5H/KJQ0acGZBw==' + }, + keyId: 1 + }, + registrationId: 138, + advSecretKey: 'gr2WI8nNQsCX3T2OFLQ4XEqOFx30KwLoNSyPuG+JBgY=', + processedHistoryMessages: [], + nextPreKeyId: 1, + firstUnuploadedPreKeyId: 1, + accountSyncCounter: 0, + accountSettings: { unarchiveChats: false }, + registered: false, + account: { + details: 'CL6U6YIIEIqcr88GGAEgACgA', + accountSignatureKey: 'Bf9vu9HN/FNRTGAM5LkecDimUtgFRJuC0UgCXs37QP9h', + accountSignature: + 'Crai7i0IfoU4QpQiHNr8puo1g9NmkhuhexqYx/2FzaJy2ZIjwuaAZzt1sNvbXSwQWYGJdkfguvLIS2uh2fJLBA==', + deviceSignature: + 'r7DNs4xAUd4SOT+hKH5wP3F2VAp5pmaS5O0rRx+QjKOvh0/FlCLh765m/bbOiCq0ijD+PX7xPl0dLVYnXZatAg==' + }, + me: { + id: '56965746475:12@s.whatsapp.net', + lid: '102513101521058:12@lid' + }, + signalIdentities: [ + { + identifier: { name: '102513101521058:12@lid', deviceId: 0 }, + identifierKey: { + type: 'Buffer', + data: 'Bf9vu9HN/FNRTGAM5LkecDimUtgFRJuC0UgCXs37QP9h' + } + } + ], + platform: 'android' +}) + +describe('convertBaileysCreds (real Baileys fixture)', () => { + it('maps every required zapo field with byte-exact key material', () => { + const fixture = JSON.parse(FIXTURE_TEXT, bufferJsonReviver) as BaileysAuthenticationCreds + const result = convertBaileysCreds(fixture) + + // Noise key — round-trip pub/priv unchanged + assert.equal(result.noiseKeyPair.pubKey.length, 32) + assert.equal(result.noiseKeyPair.privKey.length, 32) + assert.equal( + Buffer.from(result.noiseKeyPair.pubKey).toString('base64'), + 'DLdrevDQe6DysKKSUl/3IdwYUF2NzAGSyIEpCn/2CRU=' + ) + + // Identity key + assert.equal(result.registrationInfo.registrationId, 138) + assert.equal(result.registrationInfo.identityKeyPair.privKey.length, 32) + + // Signed pre-key + assert.equal(result.signedPreKey.keyId, 1) + assert.equal(result.signedPreKey.signature.length, 64) + assert.equal(result.signedPreKey.uploaded, true) + + // ADV secret — base64 → 32 bytes + assert.equal(result.advSecretKey.length, 32) + assert.equal( + Buffer.from(result.advSecretKey).toString('base64'), + 'gr2WI8nNQsCX3T2OFLQ4XEqOFx30KwLoNSyPuG+JBgY=' + ) + + // Signed identity round-trips through proto encode/decode losslessly + assert.ok(result.signedIdentity) + const encoded = proto.ADVSignedDeviceIdentity.encode(result.signedIdentity).finish() + const decoded = proto.ADVSignedDeviceIdentity.decode(encoded) + assert.equal(decoded.accountSignature?.length, 64) + assert.equal(decoded.deviceSignature?.length, 64) + assert.equal(decoded.accountSignatureKey?.length, 33) + + // Identity propagation + assert.equal(result.meJid, '56965746475:12@s.whatsapp.net') + assert.equal(result.meLid, '102513101521058:12@lid') + assert.equal(result.platform, 'android') + + // Server prekey state — fresh account, none uploaded yet + assert.equal(result.serverHasPreKeys, false) + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-keys.test.ts b/packages/store-migrate/src/__tests__/baileys-keys.test.ts new file mode 100644 index 0000000..21a58c8 --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-keys.test.ts @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { X25519 } from 'zapo-js/crypto' + +import { convertBaileysIdentityKey, convertBaileysPreKey } from '../baileys/keys' + +describe('convertBaileysPreKey', () => { + it('maps Baileys public/private into zapo pubKey/privKey', async () => { + const kp = await X25519.generateKeyPair() + const record = convertBaileysPreKey( + 7, + { public: kp.pubKey, private: kp.privKey }, + { + uploaded: true + } + ) + assert.equal(record.keyId, 7) + assert.equal(record.uploaded, true) + assert.equal(record.keyPair.pubKey, kp.pubKey) + assert.equal(record.keyPair.privKey, kp.privKey) + }) + + it('omits uploaded flag when not provided', () => { + const record = convertBaileysPreKey(1, { + public: new Uint8Array(32), + private: new Uint8Array(32) + }) + assert.equal(record.uploaded, undefined) + }) +}) + +describe('convertBaileysIdentityKey', () => { + it('parses libsignal address into a SignalAddress', () => { + const key = new Uint8Array(32) + const result = convertBaileysIdentityKey('5511999999999.4', key) + assert.equal(result.address.user, '5511999999999') + assert.equal(result.address.device, 4) + assert.equal(result.address.server, 's.whatsapp.net') + assert.equal(result.identityKey, key) + }) + + it('preserves Baileys lid suffix when stripping', () => { + const result = convertBaileysIdentityKey('102513101521058_1.12', new Uint8Array(32)) + assert.equal(result.address.user, '102513101521058') + assert.equal(result.address.server, 'lid') + assert.equal(result.address.device, 12) + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-misc.test.ts b/packages/store-migrate/src/__tests__/baileys-misc.test.ts new file mode 100644 index 0000000..f345d4c --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-misc.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { convertBaileysDeviceList } from '../baileys/device-list' +import { convertBaileysTcToken } from '../baileys/privacy-token' + +describe('convertBaileysTcToken', () => { + it('parses string timestamp and uses provided nowMs', () => { + const token = new Uint8Array([1]) + const result = convertBaileysTcToken( + '5511999999999@s.whatsapp.net', + { token, timestamp: '1700000000' }, + { nowMs: 1234 } + ) + assert.equal(result.tcToken, token) + assert.equal(result.tcTokenTimestamp, 1_700_000_000) + assert.equal(result.updatedAtMs, 1234) + }) + + it('omits tcTokenTimestamp when missing or invalid', () => { + const result = convertBaileysTcToken('x', { token: new Uint8Array() }) + assert.equal(result.tcTokenTimestamp, undefined) + const bad = convertBaileysTcToken('x', { token: new Uint8Array(), timestamp: 'abc' }) + assert.equal(bad.tcTokenTimestamp, undefined) + }) +}) + +describe('convertBaileysDeviceList', () => { + it('wraps raw device JIDs into a snapshot', () => { + const result = convertBaileysDeviceList( + '5511999999999@s.whatsapp.net', + ['5511999999999:0@s.whatsapp.net', '5511999999999:12@s.whatsapp.net'], + { nowMs: 42 } + ) + assert.equal(result.userJid, '5511999999999@s.whatsapp.net') + assert.equal(result.deviceJids.length, 2) + assert.equal(result.updatedAtMs, 42) + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-sender-key.test.ts b/packages/store-migrate/src/__tests__/baileys-sender-key.test.ts new file mode 100644 index 0000000..8b9be5e --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-sender-key.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { convertBaileysSenderKey } from '../baileys/sender-key' +import type { BaileysSenderKeyStateStructure } from '../baileys/types' + +const SIGN_PUB = (() => { + const out = new Uint8Array(33) + out[0] = 0x05 + out.fill(0xaa, 1) + return out +})() +const SIGN_PRIV = new Uint8Array(32).fill(0xbb) +const CHAIN_SEED = new Uint8Array(32).fill(0xcc) + +describe('convertBaileysSenderKey', () => { + it('promotes the latest state and maps every field', () => { + const states: BaileysSenderKeyStateStructure[] = [ + { + senderKeyId: 1, + senderChainKey: { iteration: 0, seed: new Uint8Array(32).fill(0x01) }, + senderSigningKey: { public: SIGN_PUB, private: SIGN_PRIV }, + senderMessageKeys: [] + }, + { + senderKeyId: 2, + senderChainKey: { iteration: 5, seed: CHAIN_SEED }, + senderSigningKey: { public: SIGN_PUB, private: SIGN_PRIV }, + senderMessageKeys: [ + { iteration: 1, seed: new Uint8Array(32).fill(0xdd) }, + { iteration: 2, seed: new Uint8Array(32).fill(0xee) } + ] + } + ] + + const result = convertBaileysSenderKey('120363041234567890@g.us', '5511999999999.0', states) + + assert.equal(result.groupId, '120363041234567890@g.us') + assert.equal(result.sender.user, '5511999999999') + assert.equal(result.sender.device, 0) + assert.equal(result.sender.server, 's.whatsapp.net') + + // Latest state was state[1] + assert.equal(result.keyId, 2) + assert.equal(result.iteration, 5) + assert.deepEqual(Array.from(result.chainKey), Array.from(CHAIN_SEED)) + assert.deepEqual(Array.from(result.signingPublicKey), Array.from(SIGN_PUB)) + assert.deepEqual(Array.from(result.signingPrivateKey!), Array.from(SIGN_PRIV)) + assert.equal(result.unusedMessageKeys?.length, 2) + assert.equal(result.unusedMessageKeys[0].iteration, 1) + assert.equal(result.unusedMessageKeys[1].iteration, 2) + }) + + it('accepts base64 strings for binary fields (multi-file path)', () => { + const states: BaileysSenderKeyStateStructure[] = [ + { + senderKeyId: 7, + senderChainKey: { + iteration: 0, + seed: Buffer.from(CHAIN_SEED).toString('base64') + }, + senderSigningKey: { + public: Buffer.from(SIGN_PUB).toString('base64'), + private: Buffer.from(SIGN_PRIV).toString('base64') + }, + senderMessageKeys: [] + } + ] + const result = convertBaileysSenderKey('g@g.us', '5511.0', states) + assert.deepEqual(Array.from(result.chainKey), Array.from(CHAIN_SEED)) + assert.deepEqual(Array.from(result.signingPublicKey), Array.from(SIGN_PUB)) + }) + + it('omits signing private when undefined (received SKDM)', () => { + const states: BaileysSenderKeyStateStructure[] = [ + { + senderKeyId: 3, + senderChainKey: { iteration: 0, seed: CHAIN_SEED }, + senderSigningKey: { public: SIGN_PUB }, + senderMessageKeys: [] + } + ] + const result = convertBaileysSenderKey('g@g.us', '5511.0', states) + assert.equal(result.signingPrivateKey, undefined) + }) + + it('throws on empty state array', () => { + assert.throws(() => convertBaileysSenderKey('g@g.us', '5511.0', [])) + }) +}) diff --git a/packages/store-migrate/src/__tests__/baileys-session.test.ts b/packages/store-migrate/src/__tests__/baileys-session.test.ts new file mode 100644 index 0000000..1600664 --- /dev/null +++ b/packages/store-migrate/src/__tests__/baileys-session.test.ts @@ -0,0 +1,295 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { convertBaileysSession } from '../baileys/session' +import type { + BaileysSerializedSessionEntry, + BaileysSerializedSessionRecord +} from '../baileys/types' + +const b64 = (bytes: Uint8Array): string => Buffer.from(bytes).toString('base64') + +function pubKey33(seed: number): Uint8Array { + const out = new Uint8Array(33) + out[0] = 0x05 + out.fill(seed, 1) + return out +} + +function bytes32(seed: number): Uint8Array { + return new Uint8Array(32).fill(seed) +} + +const RATCHET_PUB = pubKey33(0xaa) +const RATCHET_PRIV = bytes32(0x11) +const ROOT_KEY = bytes32(0x22) +const SEND_CHAIN_KEY = bytes32(0x33) +const REMOTE_IDENTITY = pubKey33(0xbb) +const BASE_KEY = pubKey33(0xcc) +const RECV_RATCHET = pubKey33(0xdd) +const RECV_CHAIN_KEY = bytes32(0x44) +const PENDING_BASE = pubKey33(0xee) +const LOCAL_IDENTITY_32 = bytes32(0x55) + +function makeOpenEntryAsBob(): BaileysSerializedSessionEntry { + // baseKeyType = 2 (THEIRS) — we are Bob, alice's basekey is the indexInfo.baseKey + return { + registrationId: 4242, + currentRatchet: { + ephemeralKeyPair: { + pubKey: b64(RATCHET_PUB), + privKey: b64(RATCHET_PRIV) + }, + lastRemoteEphemeralKey: b64(RECV_RATCHET), + previousCounter: 9, + rootKey: b64(ROOT_KEY) + }, + indexInfo: { + baseKey: b64(BASE_KEY), + baseKeyType: 2, + closed: -1, + used: 1_700_000_000, + created: 1_699_000_000, + remoteIdentityKey: b64(REMOTE_IDENTITY) + }, + _chains: { + [b64(RATCHET_PUB)]: { + chainKey: { counter: 7, key: b64(SEND_CHAIN_KEY) }, + chainType: 1, + messageKeys: {} + }, + [b64(RECV_RATCHET)]: { + chainKey: { counter: 3, key: b64(RECV_CHAIN_KEY) }, + chainType: 2, + messageKeys: {} + } + } + } +} + +function makePendingEntryAsAlice(): BaileysSerializedSessionEntry { + // baseKeyType = 1 (OURS) — we are Alice, indexInfo.baseKey is our ephemeral + return { + registrationId: 1234, + currentRatchet: { + ephemeralKeyPair: { + pubKey: b64(RATCHET_PUB), + privKey: b64(RATCHET_PRIV) + }, + lastRemoteEphemeralKey: b64(RECV_RATCHET), + previousCounter: 0, + rootKey: b64(ROOT_KEY) + }, + indexInfo: { + baseKey: b64(BASE_KEY), + baseKeyType: 1, + closed: -1, + used: 1_700_000_500, + created: 1_700_000_500, + remoteIdentityKey: b64(REMOTE_IDENTITY) + }, + _chains: { + [b64(RATCHET_PUB)]: { + chainKey: { counter: 0, key: b64(SEND_CHAIN_KEY) }, + chainType: 1, + messageKeys: {} + } + }, + pendingPreKey: { + signedKeyId: 17, + preKeyId: 99, + baseKey: b64(PENDING_BASE) + } + } +} + +describe('convertBaileysSession (real Baileys SessionRecord shape)', () => { + it('maps the open entry into a SignalSessionRecord with byte-correct fields', () => { + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { + [b64(BASE_KEY)]: makeOpenEntryAsBob() + } + } + + const result = convertBaileysSession('5511999999999.7', record, { + local: { regId: 8888, identityPubKey: LOCAL_IDENTITY_32 } + }) + + assert.equal(result.address.user, '5511999999999') + assert.equal(result.address.device, 7) + + const r = result.record + assert.equal(r.local.regId, 8888) + assert.equal(r.local.pubKey.length, 33) + assert.equal(r.local.pubKey[0], 0x05) + assert.equal(r.remote.regId, 4242) + assert.deepEqual(Array.from(r.remote.pubKey), Array.from(REMOTE_IDENTITY)) + assert.deepEqual(Array.from(r.rootKey), Array.from(ROOT_KEY)) + + // Send chain — pulled from _chains[base64(ratchetPub)] + assert.deepEqual(Array.from(r.sendChain.ratchetKey.pubKey), Array.from(RATCHET_PUB)) + assert.deepEqual(Array.from(r.sendChain.ratchetKey.privKey), Array.from(RATCHET_PRIV)) + assert.equal(r.sendChain.nextMsgIndex, 7) + assert.deepEqual(Array.from(r.sendChain.chainKey), Array.from(SEND_CHAIN_KEY)) + + // Recv chain — the other entry in _chains + assert.equal(r.recvChains.length, 1) + const recv = r.recvChains[0] + assert.deepEqual(Array.from(recv.senderRatchetKey!), Array.from(RECV_RATCHET)) + assert.equal(recv.chainKey?.index, 3) + assert.deepEqual(Array.from(recv.chainKey.key!), Array.from(RECV_CHAIN_KEY)) + assert.equal(recv.messageKeys?.length, 0) + + // We are Bob — aliceBaseKey is null + assert.equal(r.aliceBaseKey, null) + // No pendingPreKey on this entry + assert.equal(r.initialExchangeInfo, null) + assert.equal(r.prevSendChainHighestIndex, 9) + assert.equal(r.prevSessions.length, 0) + }) + + it('maps Alice-side pending session with aliceBaseKey + initialExchangeInfo', () => { + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { + [b64(BASE_KEY)]: makePendingEntryAsAlice() + } + } + + const result = convertBaileysSession('5511999999999.0', record, { + local: { regId: 1, identityPubKey: LOCAL_IDENTITY_32 } + }) + + assert.deepEqual(Array.from(result.record.aliceBaseKey!), Array.from(BASE_KEY)) + assert.ok(result.record.initialExchangeInfo) + assert.equal(result.record.initialExchangeInfo.remoteSignedId, 17) + assert.equal(result.record.initialExchangeInfo.remoteOneTimeId, 99) + assert.deepEqual( + Array.from(result.record.initialExchangeInfo.localOneTimePubKey), + Array.from(PENDING_BASE) + ) + }) + + it('promotes closed sessions to prevSessions, current = open one', () => { + const closedEntry: BaileysSerializedSessionEntry = { + ...makeOpenEntryAsBob(), + registrationId: 7777, + indexInfo: { + ...makeOpenEntryAsBob().indexInfo, + closed: 1_699_999_999, // closed + used: 1_699_500_000 + } + } + const openEntry = makeOpenEntryAsBob() + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { + 'old-key': closedEntry, + [b64(BASE_KEY)]: openEntry + } + } + + const result = convertBaileysSession('5511.0', record, { + local: { regId: 1, identityPubKey: LOCAL_IDENTITY_32 } + }) + + assert.equal(result.record.remote.regId, 4242) // open one + assert.equal(result.record.prevSessions.length, 1) + assert.equal(result.record.prevSessions[0].remoteRegistrationId, 7777) + assert.equal(result.record.prevSessions[0].sessionVersion, 3) + }) + + it('falls back to most-recently-used when no entry is open', () => { + const olderEntry: BaileysSerializedSessionEntry = { + ...makeOpenEntryAsBob(), + registrationId: 1111, + indexInfo: { + ...makeOpenEntryAsBob().indexInfo, + closed: 1_699_000_000, + used: 1_699_000_000 + } + } + const newerEntry: BaileysSerializedSessionEntry = { + ...makeOpenEntryAsBob(), + registrationId: 2222, + indexInfo: { + ...makeOpenEntryAsBob().indexInfo, + closed: 1_700_000_000, + used: 1_700_500_000 + } + } + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { + older: olderEntry, + newer: newerEntry + } + } + + const result = convertBaileysSession('5511.0', record, { + local: { regId: 1, identityPubKey: LOCAL_IDENTITY_32 } + }) + assert.equal(result.record.remote.regId, 2222) + assert.equal(result.record.prevSessions.length, 1) + }) + + it('accepts Uint8Array for binary fields (custom-store path)', () => { + // Same struct but using Uint8Array everywhere instead of base64 strings. + const entry: BaileysSerializedSessionEntry = { + registrationId: 4242, + currentRatchet: { + ephemeralKeyPair: { pubKey: RATCHET_PUB, privKey: RATCHET_PRIV }, + lastRemoteEphemeralKey: RECV_RATCHET, + previousCounter: 0, + rootKey: ROOT_KEY + }, + indexInfo: { + baseKey: BASE_KEY, + baseKeyType: 2, + closed: -1, + used: 0, + created: 0, + remoteIdentityKey: REMOTE_IDENTITY + }, + _chains: { + [b64(RATCHET_PUB)]: { + chainKey: { counter: 0, key: SEND_CHAIN_KEY }, + chainType: 1, + messageKeys: {} + } + } + } + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { [b64(BASE_KEY)]: entry } + } + + const result = convertBaileysSession('5511.0', record, { + local: { regId: 1, identityPubKey: LOCAL_IDENTITY_32 } + }) + assert.deepEqual(Array.from(result.record.rootKey), Array.from(ROOT_KEY)) + }) + + it('accepts a 33-byte local identityPubKey without re-prefixing', () => { + const local33 = pubKey33(0x77) + const record: BaileysSerializedSessionRecord = { + version: 'v1', + _sessions: { [b64(BASE_KEY)]: makeOpenEntryAsBob() } + } + const result = convertBaileysSession('5511.0', record, { + local: { regId: 1, identityPubKey: local33 } + }) + assert.deepEqual(Array.from(result.record.local.pubKey), Array.from(local33)) + }) + + it('throws on empty _sessions', () => { + assert.throws(() => + convertBaileysSession( + '5511.0', + { version: 'v1', _sessions: {} }, + { local: { regId: 1, identityPubKey: LOCAL_IDENTITY_32 } } + ) + ) + }) +}) diff --git a/packages/store-migrate/src/__tests__/buffer-json.test.ts b/packages/store-migrate/src/__tests__/buffer-json.test.ts new file mode 100644 index 0000000..8f2cb9b --- /dev/null +++ b/packages/store-migrate/src/__tests__/buffer-json.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { bufferJsonReviver } from '../util/buffer-json' + +describe('bufferJsonReviver', () => { + it('decodes Baileys Buffer JSON markers into Uint8Array', () => { + const text = JSON.stringify({ + blob: { type: 'Buffer', data: Buffer.from([1, 2, 3, 4]).toString('base64') } + }) + const parsed = JSON.parse(text, bufferJsonReviver) as { blob: Uint8Array } + assert.ok(parsed.blob instanceof Uint8Array) + assert.deepEqual(Array.from(parsed.blob), [1, 2, 3, 4]) + }) + + it('passes through plain values unchanged', () => { + const parsed = JSON.parse('{"a":1,"b":"x","c":[1,2]}', bufferJsonReviver) + assert.deepEqual(parsed, { a: 1, b: 'x', c: [1, 2] }) + }) + + it('does not match objects that only have data without type', () => { + const parsed = JSON.parse('{"data":"abc"}', bufferJsonReviver) as Record + assert.equal(parsed.data, 'abc') + }) +}) diff --git a/packages/store-migrate/src/__tests__/whatsmeow-appstate.test.ts b/packages/store-migrate/src/__tests__/whatsmeow-appstate.test.ts new file mode 100644 index 0000000..2e8c4b7 --- /dev/null +++ b/packages/store-migrate/src/__tests__/whatsmeow-appstate.test.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { encodeAppStateFingerprint } from 'zapo-js/appstate' +import { bytesToHex } from 'zapo-js/util' + +import { + convertWhatsmeowAppStateSyncKey, + convertWhatsmeowAppStateVersion +} from '../whatsmeow/appstate' + +describe('convertWhatsmeowAppStateSyncKey', () => { + it('decodes the embedded fingerprint protobuf', () => { + const fingerprint = encodeAppStateFingerprint({ + rawId: 42, + currentIndex: 1, + deviceIndexes: [0, 12] + }) + assert.ok(fingerprint) + const result = convertWhatsmeowAppStateSyncKey({ + key_id: new Uint8Array([1, 2, 3]), + key_data: new Uint8Array([10]), + timestamp: 1700000000n, + fingerprint: fingerprint + }) + assert.equal(result.timestamp, 1700000000) + assert.equal(result.fingerprint?.rawId, 42) + assert.deepEqual(result.fingerprint?.deviceIndexes, [0, 12]) + }) +}) + +describe('convertWhatsmeowAppStateVersion', () => { + it('joins mutation MAC rows into hex-keyed indexValueMap', () => { + const indexA = new Uint8Array([0xaa, 0xbb]) + const indexB = new Uint8Array([0xcc, 0xdd]) + const valueA = new Uint8Array([1]) + const valueB = new Uint8Array([2]) + + const result = convertWhatsmeowAppStateVersion( + { name: 'regular', version: 7, hash: new Uint8Array(128) }, + [ + { index_mac: indexA, value_mac: valueA }, + { index_mac: indexB, value_mac: valueB } + ] + ) + + assert.equal(result.collection, 'regular') + assert.equal(result.version, 7) + assert.equal(result.indexValueMap.get(bytesToHex(indexA)), valueA) + assert.equal(result.indexValueMap.get(bytesToHex(indexB)), valueB) + }) +}) diff --git a/packages/store-migrate/src/__tests__/whatsmeow-creds.test.ts b/packages/store-migrate/src/__tests__/whatsmeow-creds.test.ts new file mode 100644 index 0000000..a9f9df9 --- /dev/null +++ b/packages/store-migrate/src/__tests__/whatsmeow-creds.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { X25519 } from 'zapo-js/crypto' + +import { convertWhatsmeowDevice } from '../whatsmeow/creds' +import type { WhatsmeowDeviceRow } from '../whatsmeow/types' + +describe('convertWhatsmeowDevice', () => { + it('derives matching pub keys from the 32-byte private columns', async () => { + const noise = await X25519.generateKeyPair() + const identity = await X25519.generateKeyPair() + const signedPre = await X25519.generateKeyPair() + + const row: WhatsmeowDeviceRow = { + jid: '5511999999999.0:42@s.whatsapp.net', + lid: '102513101521058.0:42@lid', + registration_id: 1234, + noise_key: noise.privKey, + identity_key: identity.privKey, + signed_pre_key: signedPre.privKey, + signed_pre_key_id: 1, + signed_pre_key_sig: new Uint8Array(64), + adv_key: new Uint8Array(32), + adv_details: new Uint8Array([1, 2, 3]), + adv_account_sig: new Uint8Array(64), + adv_account_sig_key: new Uint8Array(32), + adv_device_sig: new Uint8Array(64), + platform: 'android', + push_name: 'Test User', + facebook_uuid: null, + lid_migration_ts: 0 + } + + const result = await convertWhatsmeowDevice(row, { serverHasPreKeyCount: 50 }) + + assert.deepEqual(Array.from(result.noiseKeyPair.pubKey), Array.from(noise.pubKey)) + assert.deepEqual( + Array.from(result.registrationInfo.identityKeyPair.pubKey), + Array.from(identity.pubKey) + ) + assert.deepEqual( + Array.from(result.signedPreKey.keyPair.pubKey), + Array.from(signedPre.pubKey) + ) + assert.equal(result.registrationInfo.registrationId, 1234) + assert.equal(result.signedPreKey.keyId, 1) + assert.equal(result.platform, 'android') + assert.equal(result.pushName, 'Test User') + assert.equal(result.serverHasPreKeys, true) + assert.ok(result.signedIdentity) + assert.equal(result.signedIdentity?.details, row.adv_details) + }) + + it('handles bigint numerics and missing optional columns', async () => { + const noise = await X25519.generateKeyPair() + const identity = await X25519.generateKeyPair() + const signedPre = await X25519.generateKeyPair() + + const row: WhatsmeowDeviceRow = { + jid: '5511.0:1@s.whatsapp.net', + registration_id: 99n, + noise_key: noise.privKey, + identity_key: identity.privKey, + signed_pre_key: signedPre.privKey, + signed_pre_key_id: 7n, + signed_pre_key_sig: new Uint8Array(64), + adv_key: new Uint8Array(32), + adv_details: new Uint8Array(), + adv_account_sig: new Uint8Array(64), + adv_account_sig_key: new Uint8Array(32), + adv_device_sig: new Uint8Array(64) + } + + const result = await convertWhatsmeowDevice(row) + assert.equal(result.registrationInfo.registrationId, 99) + assert.equal(result.signedPreKey.keyId, 7) + assert.equal(result.meLid, undefined) + assert.equal(result.platform, undefined) + assert.equal(result.serverHasPreKeys, undefined) + }) +}) diff --git a/packages/store-migrate/src/__tests__/whatsmeow-keys.test.ts b/packages/store-migrate/src/__tests__/whatsmeow-keys.test.ts new file mode 100644 index 0000000..d8d9d0f --- /dev/null +++ b/packages/store-migrate/src/__tests__/whatsmeow-keys.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { X25519 } from 'zapo-js/crypto' + +import { convertWhatsmeowIdentityKey, convertWhatsmeowPreKey } from '../whatsmeow/keys' + +describe('convertWhatsmeowPreKey', () => { + it('derives public from the 32-byte private column', async () => { + const kp = await X25519.generateKeyPair() + const result = await convertWhatsmeowPreKey({ + key_id: 5, + key: kp.privKey, + uploaded: true + }) + assert.equal(result.keyId, 5) + assert.deepEqual(Array.from(result.keyPair.pubKey), Array.from(kp.pubKey)) + assert.equal(result.uploaded, true) + }) + + it('coerces sqlite-style boolean (0/1) for uploaded', async () => { + const kp = await X25519.generateKeyPair() + const result = await convertWhatsmeowPreKey({ + key_id: 1n, + key: kp.privKey, + uploaded: 0 + }) + assert.equal(result.uploaded, false) + }) +}) + +describe('convertWhatsmeowIdentityKey', () => { + it('parses colon-style libsignal address', () => { + const identity = new Uint8Array(32) + const result = convertWhatsmeowIdentityKey({ + their_id: '5511999999999:3', + identity + }) + assert.equal(result.address.user, '5511999999999') + assert.equal(result.address.device, 3) + assert.equal(result.address.server, 's.whatsapp.net') + assert.equal(result.identityKey, identity) + }) +}) diff --git a/packages/store-migrate/src/__tests__/whatsmeow-misc.test.ts b/packages/store-migrate/src/__tests__/whatsmeow-misc.test.ts new file mode 100644 index 0000000..de05a85 --- /dev/null +++ b/packages/store-migrate/src/__tests__/whatsmeow-misc.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { convertWhatsmeowContact } from '../whatsmeow/contact' +import { convertWhatsmeowMessageSecret } from '../whatsmeow/message-secret' +import { convertWhatsmeowPrivacyToken } from '../whatsmeow/privacy-token' + +describe('convertWhatsmeowContact', () => { + it('prefers full_name over first_name and push_name', () => { + const result = convertWhatsmeowContact( + { + their_jid: '5511999999999@s.whatsapp.net', + first_name: 'Vini', + full_name: 'Vinicius', + push_name: 'V', + business_name: null, + redacted_phone: '+55 11 9****-9999' + }, + { nowMs: 1_700_000_000_000 } + ) + assert.equal(result.jid, '5511999999999@s.whatsapp.net') + assert.equal(result.displayName, 'Vinicius') + assert.equal(result.pushName, 'V') + assert.equal(result.phoneNumber, '+55 11 9****-9999') + assert.equal(result.lastUpdatedMs, 1_700_000_000_000) + }) + + it('returns undefined when no name columns are populated', () => { + const result = convertWhatsmeowContact({ their_jid: 'x@s.whatsapp.net' }) + assert.equal(result.displayName, undefined) + assert.equal(result.pushName, undefined) + }) +}) + +describe('convertWhatsmeowPrivacyToken', () => { + it('passes seconds-precision timestamps through unchanged', () => { + const token = new Uint8Array([1, 2, 3]) + const result = convertWhatsmeowPrivacyToken( + { + their_jid: '5511999999999@s.whatsapp.net', + token, + timestamp: 1_700_000_000n, + sender_timestamp: 1_700_000_500n + }, + { nowMs: 9_999 } + ) + assert.equal(result.tcToken, token) + assert.equal(result.tcTokenTimestamp, 1_700_000_000) + assert.equal(result.tcTokenSenderTimestamp, 1_700_000_500) + assert.equal(result.updatedAtMs, 9_999) + }) + + it('handles null sender_timestamp from sqlite', () => { + const result = convertWhatsmeowPrivacyToken({ + their_jid: 'x', + token: new Uint8Array(), + timestamp: 0, + sender_timestamp: null + }) + assert.equal(result.tcTokenSenderTimestamp, undefined) + }) +}) + +describe('convertWhatsmeowMessageSecret', () => { + it('flattens chat_jid + sender_jid into the WaMessageSecretEntry shape', () => { + const key = new Uint8Array([0xab]) + const result = convertWhatsmeowMessageSecret({ + chat_jid: 'chat@g.us', + sender_jid: 'sender@s.whatsapp.net', + message_id: 'ABC', + key + }) + assert.equal(result.messageId, 'ABC') + assert.equal(result.entry.secret, key) + assert.equal(result.entry.senderJid, 'sender@s.whatsapp.net') + }) +}) diff --git a/packages/store-migrate/src/__tests__/whatsmeow-session.test.ts b/packages/store-migrate/src/__tests__/whatsmeow-session.test.ts new file mode 100644 index 0000000..8aca8f6 --- /dev/null +++ b/packages/store-migrate/src/__tests__/whatsmeow-session.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + encodeSenderKeyRecord, + encodeSignalSessionRecord, + type SenderKeyRecord, + type SignalSessionRecord +} from 'zapo-js/signal' + +import { convertWhatsmeowSenderKey } from '../whatsmeow/sender-key' +import { convertWhatsmeowSession } from '../whatsmeow/session' + +function pubKey33(seed: number): Uint8Array { + const out = new Uint8Array(33) + out[0] = 0x05 + out.fill(seed, 1) + return out +} + +function buildSampleSession(): SignalSessionRecord { + return { + local: { regId: 100, pubKey: pubKey33(0xaa) }, + remote: { regId: 200, pubKey: pubKey33(0xbb) }, + rootKey: new Uint8Array(32).fill(1), + sendChain: { + ratchetKey: { pubKey: pubKey33(0xcc), privKey: new Uint8Array(32).fill(2) }, + nextMsgIndex: 0, + chainKey: new Uint8Array(32).fill(3) + }, + recvChains: [], + initialExchangeInfo: null, + prevSendChainHighestIndex: 0, + aliceBaseKey: null, + prevSessions: [] + } +} + +function buildSampleSenderKey(groupId: string): SenderKeyRecord { + return { + groupId, + sender: { user: '5511999999999', server: 's.whatsapp.net', device: 0 }, + keyId: 5, + iteration: 12, + chainKey: new Uint8Array(32).fill(4), + signingPublicKey: pubKey33(0xdd), + signingPrivateKey: new Uint8Array(32).fill(5) + } +} + +// whatsmeow's go libsignal `serialize.NewProtoBufSerializer()` emits the same +// `RecordStructure` / `SenderKeyRecordStructure` wire bytes that zapo encodes +// natively, so round-tripping through zapo's encoder is a faithful proxy. +describe('convertWhatsmeowSession (proto bytes)', () => { + it('parses colon-style address and decodes the proto record', () => { + const bytes = encodeSignalSessionRecord(buildSampleSession()) + const result = convertWhatsmeowSession({ their_id: '5511999999999:3', session: bytes }) + assert.equal(result.address.user, '5511999999999') + assert.equal(result.address.device, 3) + assert.equal(result.record.remote.regId, 200) + }) + + it('throws on garbage bytes', () => { + assert.throws(() => + convertWhatsmeowSession({ + their_id: '5511.0', + session: new Uint8Array([0xff, 0xff, 0xff]) + }) + ) + }) +}) + +describe('convertWhatsmeowSenderKey (proto bytes)', () => { + it('decodes whatsmeow row using colon-style sender id', () => { + const original = buildSampleSenderKey('group@g.us') + const bytes = encodeSenderKeyRecord(original) + const result = convertWhatsmeowSenderKey({ + chat_id: 'group@g.us', + sender_id: '5511999999999:0', + sender_key: bytes + }) + assert.equal(result.iteration, 12) + assert.equal(result.sender.device, 0) + assert.deepEqual(Array.from(result.chainKey), Array.from(original.chainKey)) + }) +}) diff --git a/packages/store-migrate/src/baileys/appstate.ts b/packages/store-migrate/src/baileys/appstate.ts new file mode 100644 index 0000000..4d906e5 --- /dev/null +++ b/packages/store-migrate/src/baileys/appstate.ts @@ -0,0 +1,50 @@ +import type { AppStateCollectionName, WaAppStateSyncKey } from 'zapo-js/appstate' +import type { WaAppStateCollectionStateUpdate } from 'zapo-js/store' +import { base64ToBytes, bytesToHex } from 'zapo-js/util' + +import type { BaileysAppStateSyncKeyData, BaileysLTHashState } from './types' + +export function convertBaileysAppStateSyncKey( + id: Uint8Array | string, + data: BaileysAppStateSyncKeyData +): WaAppStateSyncKey { + const keyId = typeof id === 'string' ? base64ToBytes(id) : id + const timestamp = + typeof data.timestamp === 'string' + ? Number.parseInt(data.timestamp, 10) + : (data.timestamp ?? 0) + return { + keyId, + keyData: data.keyData ?? new Uint8Array(0), + timestamp, + fingerprint: data.fingerprint + ? { + rawId: data.fingerprint.rawId, + currentIndex: data.fingerprint.currentIndex, + deviceIndexes: + data.fingerprint.deviceIndexes !== undefined + ? Array.from(data.fingerprint.deviceIndexes) + : undefined + } + : undefined + } +} + +/** Baileys keys `indexValueMap` by base64(indexMac); zapo expects hex. */ +export function convertBaileysAppStateVersion( + collection: AppStateCollectionName, + state: BaileysLTHashState +): WaAppStateCollectionStateUpdate { + const indexValueMap = new Map() + for (const indexMacBase64 of Object.keys(state.indexValueMap)) { + const entry = state.indexValueMap[indexMacBase64] + if (!entry) continue + indexValueMap.set(bytesToHex(base64ToBytes(indexMacBase64)), entry.valueMac) + } + return { + collection, + version: state.version, + hash: state.hash, + indexValueMap + } +} diff --git a/packages/store-migrate/src/baileys/coerce.ts b/packages/store-migrate/src/baileys/coerce.ts new file mode 100644 index 0000000..5f07955 --- /dev/null +++ b/packages/store-migrate/src/baileys/coerce.ts @@ -0,0 +1,17 @@ +import { asBytes, base64ToBytes } from 'zapo-js/util' + +import type { MaybeBytes } from './types' + +/** Decode base64 strings (Baileys session) and pass through Uint8Array (everything else). */ +export function toBytes(value: MaybeBytes, field: string): Uint8Array { + if (typeof value === 'string') return base64ToBytes(value) + return asBytes(value, `baileys.${field}`) +} + +export function toOptionalBytes( + value: MaybeBytes | null | undefined, + field: string +): Uint8Array | undefined { + if (value === null || value === undefined) return undefined + return toBytes(value, field) +} diff --git a/packages/store-migrate/src/baileys/creds.ts b/packages/store-migrate/src/baileys/creds.ts new file mode 100644 index 0000000..beac8dc --- /dev/null +++ b/packages/store-migrate/src/baileys/creds.ts @@ -0,0 +1,47 @@ +import type { WaAuthCredentials } from 'zapo-js/auth' +import { base64ToBytes } from 'zapo-js/util' + +import type { BaileysAuthenticationCreds } from './types' + +export function convertBaileysCreds(creds: BaileysAuthenticationCreds): WaAuthCredentials { + const account = creds.account + const signedIdentity = account + ? { + details: account.details ?? null, + accountSignatureKey: account.accountSignatureKey ?? null, + accountSignature: account.accountSignature ?? null, + deviceSignature: account.deviceSignature ?? null + } + : undefined + + return { + noiseKeyPair: { + pubKey: creds.noiseKey.public, + privKey: creds.noiseKey.private + }, + registrationInfo: { + registrationId: creds.registrationId, + identityKeyPair: { + pubKey: creds.signedIdentityKey.public, + privKey: creds.signedIdentityKey.private + } + }, + signedPreKey: { + keyId: creds.signedPreKey.keyId, + keyPair: { + pubKey: creds.signedPreKey.keyPair.public, + privKey: creds.signedPreKey.keyPair.private + }, + signature: creds.signedPreKey.signature, + uploaded: true + }, + advSecretKey: base64ToBytes(creds.advSecretKey), + signedIdentity, + meJid: creds.me?.id, + meLid: creds.me?.lid, + meDisplayName: creds.me?.name ?? creds.me?.notify, + platform: creds.platform, + serverHasPreKeys: creds.firstUnuploadedPreKeyId > 1, + routingInfo: creds.routingInfo + } +} diff --git a/packages/store-migrate/src/baileys/device-list.ts b/packages/store-migrate/src/baileys/device-list.ts new file mode 100644 index 0000000..803a845 --- /dev/null +++ b/packages/store-migrate/src/baileys/device-list.ts @@ -0,0 +1,13 @@ +import type { WaDeviceListSnapshot } from 'zapo-js/store' + +export function convertBaileysDeviceList( + userJid: string, + deviceJids: readonly string[], + options: { readonly nowMs?: number } = {} +): WaDeviceListSnapshot { + return { + userJid, + deviceJids, + updatedAtMs: options.nowMs ?? Date.now() + } +} diff --git a/packages/store-migrate/src/baileys/index.ts b/packages/store-migrate/src/baileys/index.ts new file mode 100644 index 0000000..efb6628 --- /dev/null +++ b/packages/store-migrate/src/baileys/index.ts @@ -0,0 +1,19 @@ +export { convertBaileysCreds } from './creds' +export { convertBaileysIdentityKey, convertBaileysPreKey } from './keys' +export { convertBaileysSession } from './session' +export { convertBaileysSenderKey } from './sender-key' +export { convertBaileysAppStateSyncKey, convertBaileysAppStateVersion } from './appstate' +export { convertBaileysTcToken } from './privacy-token' +export { convertBaileysDeviceList } from './device-list' +export type { + BaileysADVSignedDeviceIdentity, + BaileysAppStateSyncKeyData, + BaileysAuthenticationCreds, + BaileysContact, + BaileysKeyPair, + BaileysLTHashState, + BaileysProtocolAddress, + BaileysSignalIdentity, + BaileysSignedKeyPair, + BaileysTcTokenEntry +} from './types' diff --git a/packages/store-migrate/src/baileys/keys.ts b/packages/store-migrate/src/baileys/keys.ts new file mode 100644 index 0000000..c9aab65 --- /dev/null +++ b/packages/store-migrate/src/baileys/keys.ts @@ -0,0 +1,31 @@ +import type { PreKeyRecord, SignalAddress } from 'zapo-js/signal' + +import { signalAddressFromLibsignalString } from '../util/address' + +import type { BaileysKeyPair } from './types' + +export function convertBaileysPreKey( + keyId: number, + keyPair: BaileysKeyPair, + options: { readonly uploaded?: boolean } = {} +): PreKeyRecord { + return { + keyId, + keyPair: { + pubKey: keyPair.public, + privKey: keyPair.private + }, + uploaded: options.uploaded + } +} + +export function convertBaileysIdentityKey( + addrEncoded: string, + identityKey: Uint8Array, + options: { readonly server?: string } = {} +): { readonly address: SignalAddress; readonly identityKey: Uint8Array } { + return { + address: signalAddressFromLibsignalString(addrEncoded, options), + identityKey + } +} diff --git a/packages/store-migrate/src/baileys/privacy-token.ts b/packages/store-migrate/src/baileys/privacy-token.ts new file mode 100644 index 0000000..af4ee1d --- /dev/null +++ b/packages/store-migrate/src/baileys/privacy-token.ts @@ -0,0 +1,18 @@ +import type { WaStoredPrivacyTokenRecord } from 'zapo-js/store' + +import type { BaileysTcTokenEntry } from './types' + +export function convertBaileysTcToken( + jid: string, + entry: BaileysTcTokenEntry, + options: { readonly nowMs?: number } = {} +): WaStoredPrivacyTokenRecord { + const timestampSeconds = + entry.timestamp !== undefined ? Number.parseInt(entry.timestamp, 10) : undefined + return { + jid, + tcToken: entry.token, + tcTokenTimestamp: Number.isFinite(timestampSeconds) ? timestampSeconds : undefined, + updatedAtMs: options.nowMs ?? Date.now() + } +} diff --git a/packages/store-migrate/src/baileys/sender-key.ts b/packages/store-migrate/src/baileys/sender-key.ts new file mode 100644 index 0000000..6a34812 --- /dev/null +++ b/packages/store-migrate/src/baileys/sender-key.ts @@ -0,0 +1,44 @@ +import type { SenderKeyRecord } from 'zapo-js/signal' + +import { signalAddressFromLibsignalString } from '../util/address' + +import { toBytes, toOptionalBytes } from './coerce' +import type { BaileysSenderKeyStateStructure } from './types' + +/** + * Promotes the latest of Baileys' up-to-5 historical states (mirrors + * `getSenderKeyState()`); zapo holds a single state per record. + */ +export function convertBaileysSenderKey( + groupId: string, + senderAddrEncoded: string, + states: readonly BaileysSenderKeyStateStructure[], + options: { readonly server?: string } = {} +): SenderKeyRecord { + if (states.length === 0) { + throw new Error(`baileys sender-key ${groupId}/${senderAddrEncoded}: empty state array`) + } + const sender = signalAddressFromLibsignalString(senderAddrEncoded, options) + const state = states[states.length - 1] + const field = `senderKeyStates[${states.length - 1}]` + + return { + groupId, + sender, + keyId: state.senderKeyId, + iteration: state.senderChainKey.iteration, + chainKey: toBytes(state.senderChainKey.seed, `${field}.senderChainKey.seed`), + signingPublicKey: toBytes( + state.senderSigningKey.public, + `${field}.senderSigningKey.public` + ), + signingPrivateKey: toOptionalBytes( + state.senderSigningKey.private, + `${field}.senderSigningKey.private` + ), + unusedMessageKeys: state.senderMessageKeys.map((key, i) => ({ + iteration: key.iteration, + seed: toBytes(key.seed, `${field}.senderMessageKeys[${i}].seed`) + })) + } +} diff --git a/packages/store-migrate/src/baileys/session.ts b/packages/store-migrate/src/baileys/session.ts new file mode 100644 index 0000000..c0f6e05 --- /dev/null +++ b/packages/store-migrate/src/baileys/session.ts @@ -0,0 +1,285 @@ +import type { Proto } from 'zapo-js/proto' +import type { SignalAddress, SignalSessionRecord } from 'zapo-js/signal' +import { base64ToBytes, bytesToBase64 } from 'zapo-js/util' + +import { signalAddressFromLibsignalString } from '../util/address' + +import { toBytes } from './coerce' +import type { + BaileysChain, + BaileysSerializedSessionEntry, + BaileysSerializedSessionRecord +} from './types' + +const SIGNAL_VERSION = 3 +const CHAIN_TYPE_SENDING = 1 +const BASE_KEY_TYPE_OURS = 1 + +/** + * Local pair isn't in the Baileys session entry — it lives in `creds.registrationId` + * and `creds.signedIdentityKey.public`. The 32-byte raw form is auto-prefixed to 33B. + */ +export interface BaileysLocalIdentity { + readonly regId: number + readonly identityPubKey: Uint8Array +} + +function normalizeKey33(input: Uint8Array, field: string): Uint8Array { + if (input.length === 33) return input + if (input.length === 32) { + const out = new Uint8Array(33) + out[0] = 0x05 + out.set(input, 1) + return out + } + throw new Error(`baileys.${field}: expected 32 or 33 bytes, got ${input.length}`) +} + +function pickCurrentEntry( + sessions: Readonly> +): { readonly key: string; readonly entry: BaileysSerializedSessionEntry } | null { + const entries = Object.entries(sessions) + if (entries.length === 0) return null + + let openKey: string | null = null + let openEntry: BaileysSerializedSessionEntry | null = null + let fallbackKey: string | null = null + let fallbackEntry: BaileysSerializedSessionEntry | null = null + let fallbackUsed = -1 + + for (let i = 0; i < entries.length; i += 1) { + const [k, e] = entries[i] + if (e.indexInfo.closed === -1 && openEntry === null) { + openKey = k + openEntry = e + } + const used = e.indexInfo.used ?? 0 + if (used >= fallbackUsed) { + fallbackKey = k + fallbackEntry = e + fallbackUsed = used + } + } + + if (openKey !== null && openEntry !== null) { + return { key: openKey, entry: openEntry } + } + if (fallbackKey !== null && fallbackEntry !== null) { + return { key: fallbackKey, entry: fallbackEntry } + } + return null +} + +function findSendChain( + entry: BaileysSerializedSessionEntry, + ratchetPubKeyBytes: Uint8Array +): { readonly key: string; readonly chain: BaileysChain } | null { + const expected = bytesToBase64(ratchetPubKeyBytes) + const direct = entry._chains[expected] + if (direct) return { key: expected, chain: direct } + for (const key of Object.keys(entry._chains)) { + const chain = entry._chains[key] + if (chain.chainType === CHAIN_TYPE_SENDING) return { key, chain } + } + return null +} + +function recvChainsProtoFromEntry( + entry: BaileysSerializedSessionEntry, + sendChainKey: string | null +): Proto.SessionStructure.IChain[] { + // messageKeys dropped: Baileys stores raw HKDF seeds, zapo expects + // pre-derived {cipherKey,macKey,iv} — not interconvertible without re-running KDF. + const out: Proto.SessionStructure.IChain[] = [] + for (const key of Object.keys(entry._chains)) { + if (key === sendChainKey) continue + const chain = entry._chains[key] + if (chain.chainType === CHAIN_TYPE_SENDING) continue + const ratchetPubKey = normalizeKey33( + base64ToBytes(key), + `recvChain[${key}].senderRatchetKey` + ) + out.push({ + senderRatchetKey: ratchetPubKey, + chainKey: { + index: chain.chainKey.counter, + key: toBytes(chain.chainKey.key, `recvChain[${key}].chainKey.key`) + }, + messageKeys: [] + }) + } + return out +} + +function snapshotProtoFromEntry( + entry: BaileysSerializedSessionEntry, + local: BaileysLocalIdentity, + field: string +): Proto.ISessionStructure { + const ratchetPubKey = normalizeKey33( + toBytes(entry.currentRatchet.ephemeralKeyPair.pubKey, `${field}.currentRatchet.pubKey`), + `${field}.currentRatchet.pubKey` + ) + const ratchetPrivKey = toBytes( + entry.currentRatchet.ephemeralKeyPair.privKey, + `${field}.currentRatchet.privKey` + ) + const sendInfo = findSendChain(entry, ratchetPubKey) + const sendChainKey = sendInfo?.key ?? null + + const remotePubKey = normalizeKey33( + toBytes(entry.indexInfo.remoteIdentityKey, `${field}.indexInfo.remoteIdentityKey`), + `${field}.indexInfo.remoteIdentityKey` + ) + const localPubKey = normalizeKey33(local.identityPubKey, 'local.identityPubKey') + + const senderChain: Proto.SessionStructure.IChain = sendInfo + ? { + senderRatchetKey: ratchetPubKey, + senderRatchetKeyPrivate: ratchetPrivKey, + chainKey: { + index: sendInfo.chain.chainKey.counter, + key: toBytes(sendInfo.chain.chainKey.key, `${field}.sendChain.key`) + }, + messageKeys: [] + } + : { + senderRatchetKey: ratchetPubKey, + senderRatchetKeyPrivate: ratchetPrivKey, + chainKey: { index: 0, key: new Uint8Array(32) }, + messageKeys: [] + } + + const aliceBaseKey = + entry.indexInfo.baseKeyType === BASE_KEY_TYPE_OURS + ? normalizeKey33( + toBytes(entry.indexInfo.baseKey, `${field}.indexInfo.baseKey`), + `${field}.indexInfo.baseKey` + ) + : undefined + + const pendingPreKey: Proto.SessionStructure.IPendingPreKey | undefined = entry.pendingPreKey + ? { + preKeyId: entry.pendingPreKey.preKeyId, + signedPreKeyId: entry.pendingPreKey.signedKeyId, + baseKey: normalizeKey33( + toBytes(entry.pendingPreKey.baseKey, `${field}.pendingPreKey.baseKey`), + `${field}.pendingPreKey.baseKey` + ) + } + : undefined + + return { + sessionVersion: SIGNAL_VERSION, + localRegistrationId: local.regId, + localIdentityPublic: localPubKey, + remoteRegistrationId: entry.registrationId, + remoteIdentityPublic: remotePubKey, + rootKey: toBytes(entry.currentRatchet.rootKey, `${field}.currentRatchet.rootKey`), + previousCounter: entry.currentRatchet.previousCounter, + senderChain, + receiverChains: recvChainsProtoFromEntry(entry, sendChainKey), + pendingPreKey, + aliceBaseKey + } +} + +function recordFromEntry( + entry: BaileysSerializedSessionEntry, + local: BaileysLocalIdentity, + field: string, + prevSessions: readonly Proto.ISessionStructure[] +): SignalSessionRecord { + const ratchetPubKey = normalizeKey33( + toBytes(entry.currentRatchet.ephemeralKeyPair.pubKey, `${field}.currentRatchet.pubKey`), + `${field}.currentRatchet.pubKey` + ) + const ratchetPrivKey = toBytes( + entry.currentRatchet.ephemeralKeyPair.privKey, + `${field}.currentRatchet.privKey` + ) + const sendInfo = findSendChain(entry, ratchetPubKey) + const sendChainKey = sendInfo?.key ?? null + + const remotePubKey = normalizeKey33( + toBytes(entry.indexInfo.remoteIdentityKey, `${field}.indexInfo.remoteIdentityKey`), + `${field}.indexInfo.remoteIdentityKey` + ) + const localPubKey = normalizeKey33(local.identityPubKey, 'local.identityPubKey') + + const sendChain = sendInfo + ? { + ratchetKey: { pubKey: ratchetPubKey, privKey: ratchetPrivKey }, + nextMsgIndex: sendInfo.chain.chainKey.counter, + chainKey: toBytes(sendInfo.chain.chainKey.key, `${field}.sendChain.key`) + } + : { + ratchetKey: { pubKey: ratchetPubKey, privKey: ratchetPrivKey }, + nextMsgIndex: 0, + chainKey: new Uint8Array(32) + } + + const aliceBaseKey = + entry.indexInfo.baseKeyType === BASE_KEY_TYPE_OURS + ? normalizeKey33( + toBytes(entry.indexInfo.baseKey, `${field}.indexInfo.baseKey`), + `${field}.indexInfo.baseKey` + ) + : null + + const initialExchangeInfo = entry.pendingPreKey + ? { + remoteOneTimeId: entry.pendingPreKey.preKeyId ?? null, + remoteSignedId: entry.pendingPreKey.signedKeyId, + localOneTimePubKey: normalizeKey33( + toBytes(entry.pendingPreKey.baseKey, `${field}.pendingPreKey.baseKey`), + `${field}.pendingPreKey.baseKey` + ) + } + : null + + return { + local: { regId: local.regId, pubKey: localPubKey }, + remote: { regId: entry.registrationId, pubKey: remotePubKey }, + rootKey: toBytes(entry.currentRatchet.rootKey, `${field}.currentRatchet.rootKey`), + sendChain, + recvChains: recvChainsProtoFromEntry(entry, sendChainKey), + initialExchangeInfo, + prevSendChainHighestIndex: entry.currentRatchet.previousCounter, + aliceBaseKey, + prevSessions + } +} + +/** + * Input is the plain JS object produced by libsignal-node's `SessionRecord.serialize()`, + * NOT proto bytes. Open entry (closed === -1) becomes current; closed history + * goes to `prevSessions`. Falls back to most-recently-used when none are open. + */ +export function convertBaileysSession( + addrEncoded: string, + serialized: BaileysSerializedSessionRecord, + options: { + readonly local: BaileysLocalIdentity + readonly server?: string + } +): { readonly address: SignalAddress; readonly record: SignalSessionRecord } { + const address = signalAddressFromLibsignalString(addrEncoded, { server: options.server }) + const current = pickCurrentEntry(serialized._sessions) + if (!current) { + throw new Error(`baileys session ${addrEncoded}: empty _sessions`) + } + + const prevSessions: Proto.ISessionStructure[] = [] + for (const key of Object.keys(serialized._sessions)) { + if (key === current.key) continue + prevSessions.push( + snapshotProtoFromEntry(serialized._sessions[key], options.local, `prevSessions[${key}]`) + ) + } + + return { + address, + record: recordFromEntry(current.entry, options.local, 'currentSession', prevSessions) + } +} diff --git a/packages/store-migrate/src/baileys/types.ts b/packages/store-migrate/src/baileys/types.ts new file mode 100644 index 0000000..35a7398 --- /dev/null +++ b/packages/store-migrate/src/baileys/types.ts @@ -0,0 +1,169 @@ +// Shapes mirror Baileys' multi-file auth state after JSON parse + bufferJsonReviver. +// No runtime dep on Baileys. + +export interface BaileysKeyPair { + readonly public: Uint8Array + readonly private: Uint8Array +} + +export interface BaileysSignedKeyPair { + readonly keyPair: BaileysKeyPair + readonly signature: Uint8Array + readonly keyId: number + readonly timestampS?: number +} + +export interface BaileysProtocolAddress { + readonly name: string + readonly deviceId: number +} + +export interface BaileysSignalIdentity { + readonly identifier: BaileysProtocolAddress + readonly identifierKey: Uint8Array +} + +export interface BaileysADVSignedDeviceIdentity { + readonly details?: Uint8Array + readonly accountSignatureKey?: Uint8Array + readonly accountSignature?: Uint8Array + readonly deviceSignature?: Uint8Array +} + +export interface BaileysContact { + readonly id: string + readonly lid?: string + readonly name?: string + readonly notify?: string + readonly verifiedName?: string + readonly imgUrl?: string | null + readonly status?: string +} + +export interface BaileysAuthenticationCreds { + readonly noiseKey: BaileysKeyPair + readonly pairingEphemeralKeyPair: BaileysKeyPair + readonly signedIdentityKey: BaileysKeyPair + readonly signedPreKey: BaileysSignedKeyPair + readonly registrationId: number + /** base64 of the 32-byte secret (Baileys persists it as a string). */ + readonly advSecretKey: string + readonly me?: BaileysContact + readonly account?: BaileysADVSignedDeviceIdentity + readonly signalIdentities?: readonly BaileysSignalIdentity[] + readonly myAppStateKeyId?: string + readonly firstUnuploadedPreKeyId: number + readonly nextPreKeyId: number + readonly lastAccountSyncTimestamp?: number + readonly platform?: string + readonly accountSyncCounter: number + readonly registered: boolean + readonly pairingCode?: string + readonly lastPropHash?: string + readonly routingInfo?: Uint8Array +} + +export interface BaileysAppStateSyncKeyData { + readonly keyData?: Uint8Array + readonly fingerprint?: { + readonly rawId?: number + readonly currentIndex?: number + readonly deviceIndexes?: readonly number[] + } + readonly timestamp?: number | string +} + +export interface BaileysLTHashState { + readonly version: number + readonly hash: Uint8Array + readonly indexValueMap: Readonly> +} + +export interface BaileysTcTokenEntry { + readonly token: Uint8Array + readonly timestamp?: string +} + +// `SessionRecord.serialize()` does NOT produce proto bytes — it returns a plain +// object with base64 strings inline. Custom stores that round-trip through +// BufferJSON end up with Uint8Array instead. Both forms accepted via MaybeBytes. + +export type MaybeBytes = Uint8Array | string + +export interface BaileysChainKey { + readonly counter: number + readonly key: MaybeBytes +} + +export type BaileysMessageKeys = Readonly> + +export interface BaileysChain { + readonly chainKey: BaileysChainKey + /** `1` = sending, `2` = receiving. */ + readonly chainType: number + readonly messageKeys: BaileysMessageKeys +} + +export interface BaileysCurrentRatchet { + readonly ephemeralKeyPair: { + readonly pubKey: MaybeBytes + readonly privKey: MaybeBytes + } + readonly lastRemoteEphemeralKey: MaybeBytes + readonly previousCounter: number + readonly rootKey: MaybeBytes +} + +export interface BaileysIndexInfo { + readonly baseKey: MaybeBytes + /** `1` = OURS (alice), `2` = THEIRS (bob). */ + readonly baseKeyType: number + /** `-1` when open. */ + readonly closed: number + readonly used: number + readonly created: number + readonly remoteIdentityKey: MaybeBytes +} + +export interface BaileysPendingPreKey { + readonly preKeyId?: number + /** Baileys' field name — maps to zapo's `signedPreKeyId`. */ + readonly signedKeyId: number + readonly baseKey: MaybeBytes +} + +export interface BaileysSerializedSessionEntry { + /** Remote regId; local one comes from creds. */ + readonly registrationId: number + readonly currentRatchet: BaileysCurrentRatchet + readonly indexInfo: BaileysIndexInfo + readonly _chains: Readonly> + readonly pendingPreKey?: BaileysPendingPreKey +} + +export interface BaileysSerializedSessionRecord { + readonly _sessions: Readonly> + readonly version: string +} + +export interface BaileysSenderChainKey { + readonly iteration: number + readonly seed: MaybeBytes +} + +export interface BaileysSenderSigningKey { + readonly public: MaybeBytes + readonly private?: MaybeBytes +} + +export interface BaileysSenderMessageKey { + readonly iteration: number + readonly seed: MaybeBytes +} + +export interface BaileysSenderKeyStateStructure { + readonly senderKeyId: number + readonly senderChainKey: BaileysSenderChainKey + readonly senderSigningKey: BaileysSenderSigningKey + readonly senderMessageKeys: readonly BaileysSenderMessageKey[] +} diff --git a/packages/store-migrate/src/index.ts b/packages/store-migrate/src/index.ts new file mode 100644 index 0000000..f46efee --- /dev/null +++ b/packages/store-migrate/src/index.ts @@ -0,0 +1,6 @@ +export { bufferJsonReviver } from './util/buffer-json' +export { parseLibsignalAddressString, signalAddressFromLibsignalString } from './util/address' +export { keyPairFromPrivate } from './util/keys' + +export * from './baileys/index' +export * from './whatsmeow/index' diff --git a/packages/store-migrate/src/util/address.ts b/packages/store-migrate/src/util/address.ts new file mode 100644 index 0000000..4290859 --- /dev/null +++ b/packages/store-migrate/src/util/address.ts @@ -0,0 +1,64 @@ +import { WA_DEFAULTS } from 'zapo-js/protocol' +import type { SignalAddress } from 'zapo-js/signal' + +const HOST = WA_DEFAULTS.HOST_DOMAIN +const LID = WA_DEFAULTS.LID_SERVER + +/** + * Parses a libsignal `ProtocolAddress.toString()` value. Baileys uses `.`, + * whatsmeow uses `:` — split on the rightmost separator either way. + */ +export function parseLibsignalAddressString(encoded: string): { + readonly id: string + readonly device: number +} { + const lastDot = encoded.lastIndexOf('.') + const lastColon = encoded.lastIndexOf(':') + const sepIndex = Math.max(lastDot, lastColon) + if (sepIndex <= 0 || sepIndex >= encoded.length - 1) { + throw new Error(`invalid libsignal address: ${encoded}`) + } + const idPart = encoded.slice(0, sepIndex) + const devicePart = encoded.slice(sepIndex + 1) + let device = 0 + for (let i = 0; i < devicePart.length; i += 1) { + const digit = devicePart.charCodeAt(i) - 48 + if (digit < 0 || digit > 9) { + throw new Error(`invalid libsignal address device: ${encoded}`) + } + device = device * 10 + digit + if (device > Number.MAX_SAFE_INTEGER) { + throw new Error(`invalid libsignal address device: ${encoded}`) + } + } + return { id: idPart, device } +} + +/** + * When `server` is omitted, a `_` suffix on the id (e.g. `123_1`) is read + * as Baileys' non-WhatsApp domain marker and the server defaults to `lid`. + */ +export function signalAddressFromLibsignalString( + encoded: string, + options: { readonly server?: string } = {} +): SignalAddress { + const { id, device } = parseLibsignalAddressString(encoded) + if (options.server !== undefined) { + return { user: id, server: options.server, device } + } + const underscoreIndex = id.lastIndexOf('_') + if (underscoreIndex > 0 && underscoreIndex < id.length - 1) { + let allDigits = true + for (let i = underscoreIndex + 1; i < id.length; i += 1) { + const code = id.charCodeAt(i) + if (code < 48 || code > 57) { + allDigits = false + break + } + } + if (allDigits) { + return { user: id.slice(0, underscoreIndex), server: LID, device } + } + } + return { user: id, server: HOST, device } +} diff --git a/packages/store-migrate/src/util/buffer-json.ts b/packages/store-migrate/src/util/buffer-json.ts new file mode 100644 index 0000000..20e396a --- /dev/null +++ b/packages/store-migrate/src/util/buffer-json.ts @@ -0,0 +1,14 @@ +import { base64ToBytes } from 'zapo-js/util' + +/** Reviver for Baileys' `BufferJSON` shape (`{ type: 'Buffer', data: '' }`). */ +export function bufferJsonReviver(_: string, value: unknown): unknown { + if ( + typeof value === 'object' && + value !== null && + (value as { type?: unknown }).type === 'Buffer' && + typeof (value as { data?: unknown }).data === 'string' + ) { + return base64ToBytes((value as { data: string }).data) + } + return value +} diff --git a/packages/store-migrate/src/util/keys.ts b/packages/store-migrate/src/util/keys.ts new file mode 100644 index 0000000..12a96c2 --- /dev/null +++ b/packages/store-migrate/src/util/keys.ts @@ -0,0 +1,5 @@ +import { type SignalKeyPair, X25519 } from 'zapo-js/crypto' + +export async function keyPairFromPrivate(privKey: Uint8Array): Promise { + return X25519.keyPairFromPrivateKey(privKey) +} diff --git a/packages/store-migrate/src/whatsmeow/appstate.ts b/packages/store-migrate/src/whatsmeow/appstate.ts new file mode 100644 index 0000000..803b34b --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/appstate.ts @@ -0,0 +1,43 @@ +import { + type AppStateCollectionName, + decodeAppStateFingerprint, + type WaAppStateSyncKey +} from 'zapo-js/appstate' +import type { WaAppStateCollectionStateUpdate } from 'zapo-js/store' +import { bytesToHex } from 'zapo-js/util' + +import { toNumber } from './numeric' +import type { + WhatsmeowAppStateMutationMacRow, + WhatsmeowAppStateSyncKeyRow, + WhatsmeowAppStateVersionRow +} from './types' + +export function convertWhatsmeowAppStateSyncKey( + row: WhatsmeowAppStateSyncKeyRow +): WaAppStateSyncKey { + return { + keyId: row.key_id, + keyData: row.key_data, + timestamp: toNumber(row.timestamp, 'app_state_sync_keys.timestamp'), + fingerprint: decodeAppStateFingerprint(row.fingerprint) + } +} + +/** Joins version + mutation_macs rows; zapo persists them inline keyed by hex(indexMac). */ +export function convertWhatsmeowAppStateVersion( + versionRow: WhatsmeowAppStateVersionRow, + mutationRows: readonly WhatsmeowAppStateMutationMacRow[] +): WaAppStateCollectionStateUpdate { + const indexValueMap = new Map() + for (let i = 0; i < mutationRows.length; i += 1) { + const mut = mutationRows[i] + indexValueMap.set(bytesToHex(mut.index_mac), mut.value_mac) + } + return { + collection: versionRow.name as AppStateCollectionName, + version: toNumber(versionRow.version, 'app_state_version.version'), + hash: versionRow.hash, + indexValueMap + } +} diff --git a/packages/store-migrate/src/whatsmeow/contact.ts b/packages/store-migrate/src/whatsmeow/contact.ts new file mode 100644 index 0000000..60df002 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/contact.ts @@ -0,0 +1,18 @@ +import type { WaStoredContactRecord } from 'zapo-js/store' + +import type { WhatsmeowContactRow } from './types' + +export function convertWhatsmeowContact( + row: WhatsmeowContactRow, + options: { readonly nowMs?: number } = {} +): WaStoredContactRecord { + const displayName = + row.full_name ?? row.first_name ?? row.push_name ?? row.business_name ?? undefined + return { + jid: row.their_jid, + displayName, + pushName: row.push_name ?? undefined, + phoneNumber: row.redacted_phone ?? undefined, + lastUpdatedMs: options.nowMs ?? Date.now() + } +} diff --git a/packages/store-migrate/src/whatsmeow/creds.ts b/packages/store-migrate/src/whatsmeow/creds.ts new file mode 100644 index 0000000..527fa4b --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/creds.ts @@ -0,0 +1,48 @@ +import type { WaAuthCredentials } from 'zapo-js/auth' + +import { keyPairFromPrivate } from '../util/keys' + +import { toNumber } from './numeric' +import type { WhatsmeowDeviceRow } from './types' + +/** Async: whatsmeow only persists the 32B X25519 privates; publics are derived. */ +export async function convertWhatsmeowDevice( + row: WhatsmeowDeviceRow, + options: { readonly serverHasPreKeyCount?: number } = {} +): Promise { + const [noisePair, identityPair, signedPreKeyPair] = await Promise.all([ + keyPairFromPrivate(row.noise_key), + keyPairFromPrivate(row.identity_key), + keyPairFromPrivate(row.signed_pre_key) + ]) + + return { + noiseKeyPair: noisePair, + registrationInfo: { + registrationId: toNumber(row.registration_id, 'registration_id'), + identityKeyPair: identityPair + }, + signedPreKey: { + keyId: toNumber(row.signed_pre_key_id, 'signed_pre_key_id'), + keyPair: signedPreKeyPair, + signature: row.signed_pre_key_sig, + uploaded: true + }, + advSecretKey: row.adv_key, + signedIdentity: { + details: row.adv_details, + accountSignatureKey: row.adv_account_sig_key, + accountSignature: row.adv_account_sig, + deviceSignature: row.adv_device_sig + }, + meJid: row.jid, + meLid: row.lid ?? undefined, + meDisplayName: row.push_name ?? undefined, + platform: row.platform ?? undefined, + pushName: row.push_name ?? undefined, + serverHasPreKeys: + options.serverHasPreKeyCount !== undefined + ? options.serverHasPreKeyCount > 0 + : undefined + } +} diff --git a/packages/store-migrate/src/whatsmeow/index.ts b/packages/store-migrate/src/whatsmeow/index.ts new file mode 100644 index 0000000..b8a0c63 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/index.ts @@ -0,0 +1,24 @@ +export { convertWhatsmeowDevice } from './creds' +export { convertWhatsmeowIdentityKey, convertWhatsmeowPreKey } from './keys' +export { convertWhatsmeowSession } from './session' +export { convertWhatsmeowSenderKey } from './sender-key' +export { convertWhatsmeowAppStateSyncKey, convertWhatsmeowAppStateVersion } from './appstate' +export { convertWhatsmeowContact } from './contact' +export { convertWhatsmeowPrivacyToken } from './privacy-token' +export { convertWhatsmeowMessageSecret } from './message-secret' +export type { + WhatsmeowAppStateMutationMacRow, + WhatsmeowAppStateSyncKeyRow, + WhatsmeowAppStateVersionRow, + WhatsmeowBoolean, + WhatsmeowContactRow, + WhatsmeowDeviceRow, + WhatsmeowIdentityKeyRow, + WhatsmeowLidMappingRow, + WhatsmeowMessageSecretRow, + WhatsmeowNumeric, + WhatsmeowPreKeyRow, + WhatsmeowPrivacyTokenRow, + WhatsmeowSenderKeyRow, + WhatsmeowSessionRow +} from './types' diff --git a/packages/store-migrate/src/whatsmeow/keys.ts b/packages/store-migrate/src/whatsmeow/keys.ts new file mode 100644 index 0000000..ed16f85 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/keys.ts @@ -0,0 +1,27 @@ +import type { PreKeyRecord, SignalAddress } from 'zapo-js/signal' + +import { signalAddressFromLibsignalString } from '../util/address' +import { keyPairFromPrivate } from '../util/keys' + +import { toBool, toNumber } from './numeric' +import type { WhatsmeowIdentityKeyRow, WhatsmeowPreKeyRow } from './types' + +/** Async: only the private is stored, public derived via X25519. */ +export async function convertWhatsmeowPreKey(row: WhatsmeowPreKeyRow): Promise { + const keyPair = await keyPairFromPrivate(row.key) + return { + keyId: toNumber(row.key_id, 'pre_keys.key_id'), + keyPair, + uploaded: toBool(row.uploaded) + } +} + +export function convertWhatsmeowIdentityKey( + row: WhatsmeowIdentityKeyRow, + options: { readonly server?: string } = {} +): { readonly address: SignalAddress; readonly identityKey: Uint8Array } { + return { + address: signalAddressFromLibsignalString(row.their_id, options), + identityKey: row.identity + } +} diff --git a/packages/store-migrate/src/whatsmeow/message-secret.ts b/packages/store-migrate/src/whatsmeow/message-secret.ts new file mode 100644 index 0000000..60fcc8e --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/message-secret.ts @@ -0,0 +1,16 @@ +import type { WaMessageSecretEntry } from 'zapo-js/store' + +import type { WhatsmeowMessageSecretRow } from './types' + +export function convertWhatsmeowMessageSecret(row: WhatsmeowMessageSecretRow): { + readonly messageId: string + readonly entry: WaMessageSecretEntry +} { + return { + messageId: row.message_id, + entry: { + secret: row.key, + senderJid: row.sender_jid + } + } +} diff --git a/packages/store-migrate/src/whatsmeow/numeric.ts b/packages/store-migrate/src/whatsmeow/numeric.ts new file mode 100644 index 0000000..b70d191 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/numeric.ts @@ -0,0 +1,23 @@ +import type { WhatsmeowBoolean, WhatsmeowNumeric } from './types' + +const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER) + +export function toNumber(value: WhatsmeowNumeric, field: string): number { + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error(`whatsmeow.${field}: non-finite number`) + return value + } + if (typeof value === 'bigint') { + if (value > MAX_SAFE) throw new Error(`whatsmeow.${field}: bigint exceeds safe integer`) + return Number(value) + } + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) throw new Error(`whatsmeow.${field}: invalid numeric string`) + return parsed +} + +export function toBool(value: WhatsmeowBoolean): boolean { + if (typeof value === 'boolean') return value + if (typeof value === 'bigint') return value !== 0n + return value !== 0 +} diff --git a/packages/store-migrate/src/whatsmeow/privacy-token.ts b/packages/store-migrate/src/whatsmeow/privacy-token.ts new file mode 100644 index 0000000..07b1892 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/privacy-token.ts @@ -0,0 +1,20 @@ +import type { WaStoredPrivacyTokenRecord } from 'zapo-js/store' + +import { toNumber } from './numeric' +import type { WhatsmeowPrivacyTokenRow } from './types' + +export function convertWhatsmeowPrivacyToken( + row: WhatsmeowPrivacyTokenRow, + options: { readonly nowMs?: number } = {} +): WaStoredPrivacyTokenRecord { + return { + jid: row.their_jid, + tcToken: row.token, + tcTokenTimestamp: toNumber(row.timestamp, 'privacy_tokens.timestamp'), + tcTokenSenderTimestamp: + row.sender_timestamp !== null && row.sender_timestamp !== undefined + ? toNumber(row.sender_timestamp, 'privacy_tokens.sender_timestamp') + : undefined, + updatedAtMs: options.nowMs ?? Date.now() + } +} diff --git a/packages/store-migrate/src/whatsmeow/sender-key.ts b/packages/store-migrate/src/whatsmeow/sender-key.ts new file mode 100644 index 0000000..8a777e8 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/sender-key.ts @@ -0,0 +1,13 @@ +import { decodeSenderKeyRecord, type SenderKeyRecord } from 'zapo-js/signal' + +import { signalAddressFromLibsignalString } from '../util/address' + +import type { WhatsmeowSenderKeyRow } from './types' + +export function convertWhatsmeowSenderKey( + row: WhatsmeowSenderKeyRow, + options: { readonly server?: string } = {} +): SenderKeyRecord { + const sender = signalAddressFromLibsignalString(row.sender_id, options) + return decodeSenderKeyRecord(row.sender_key, row.chat_id, sender) +} diff --git a/packages/store-migrate/src/whatsmeow/session.ts b/packages/store-migrate/src/whatsmeow/session.ts new file mode 100644 index 0000000..adf4eb1 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/session.ts @@ -0,0 +1,19 @@ +import { + decodeSignalSessionRecord, + type SignalAddress, + type SignalSessionRecord +} from 'zapo-js/signal' + +import { signalAddressFromLibsignalString } from '../util/address' + +import type { WhatsmeowSessionRow } from './types' + +export function convertWhatsmeowSession( + row: WhatsmeowSessionRow, + options: { readonly server?: string } = {} +): { readonly address: SignalAddress; readonly record: SignalSessionRecord } { + return { + address: signalAddressFromLibsignalString(row.their_id, options), + record: decodeSignalSessionRecord(row.session) + } +} diff --git a/packages/store-migrate/src/whatsmeow/types.ts b/packages/store-migrate/src/whatsmeow/types.ts new file mode 100644 index 0000000..178b819 --- /dev/null +++ b/packages/store-migrate/src/whatsmeow/types.ts @@ -0,0 +1,95 @@ +// Mirrors whatsmeow's SQL column names so callers pass `db.get(...)` rows directly. +// Numerics arrive as number / bigint / string depending on driver — accept all three. + +export type WhatsmeowNumeric = number | bigint | string + +export type WhatsmeowBoolean = boolean | number | bigint + +export interface WhatsmeowDeviceRow { + readonly jid: string + readonly lid?: string | null + readonly registration_id: WhatsmeowNumeric + readonly noise_key: Uint8Array + readonly identity_key: Uint8Array + readonly signed_pre_key: Uint8Array + readonly signed_pre_key_id: WhatsmeowNumeric + readonly signed_pre_key_sig: Uint8Array + readonly adv_key: Uint8Array + readonly adv_details: Uint8Array + readonly adv_account_sig: Uint8Array + readonly adv_account_sig_key: Uint8Array + readonly adv_device_sig: Uint8Array + readonly platform?: string | null + readonly business_name?: string | null + readonly push_name?: string | null + readonly facebook_uuid?: string | null + readonly lid_migration_ts?: WhatsmeowNumeric | null +} + +export interface WhatsmeowPreKeyRow { + readonly key_id: WhatsmeowNumeric + readonly key: Uint8Array + readonly uploaded: WhatsmeowBoolean +} + +export interface WhatsmeowSessionRow { + readonly their_id: string + readonly session: Uint8Array +} + +export interface WhatsmeowIdentityKeyRow { + readonly their_id: string + readonly identity: Uint8Array +} + +export interface WhatsmeowSenderKeyRow { + readonly chat_id: string + readonly sender_id: string + readonly sender_key: Uint8Array +} + +export interface WhatsmeowAppStateSyncKeyRow { + readonly key_id: Uint8Array + readonly key_data: Uint8Array + readonly timestamp: WhatsmeowNumeric + readonly fingerprint: Uint8Array +} + +export interface WhatsmeowAppStateVersionRow { + readonly name: string + readonly version: WhatsmeowNumeric + readonly hash: Uint8Array +} + +export interface WhatsmeowAppStateMutationMacRow { + readonly index_mac: Uint8Array + readonly value_mac: Uint8Array +} + +export interface WhatsmeowContactRow { + readonly their_jid: string + readonly first_name?: string | null + readonly full_name?: string | null + readonly push_name?: string | null + readonly business_name?: string | null + readonly redacted_phone?: string | null +} + +export interface WhatsmeowPrivacyTokenRow { + readonly their_jid: string + readonly token: Uint8Array + readonly timestamp: WhatsmeowNumeric + readonly sender_timestamp?: WhatsmeowNumeric | null +} + +export interface WhatsmeowMessageSecretRow { + readonly chat_jid: string + readonly sender_jid: string + readonly message_id: string + readonly key: Uint8Array +} + +export interface WhatsmeowLidMappingRow { + readonly lid: string + readonly pn: string +} diff --git a/packages/store-migrate/tsconfig.build.cjs.json b/packages/store-migrate/tsconfig.build.cjs.json new file mode 100644 index 0000000..5003553 --- /dev/null +++ b/packages/store-migrate/tsconfig.build.cjs.json @@ -0,0 +1,14 @@ +{ + "extends": ["../../tsconfig.packages.json", "../tsconfig.build.paths.json"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "types": ["node"], + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/packages/store-migrate/tsconfig.json b/packages/store-migrate/tsconfig.json new file mode 100644 index 0000000..a9597a9 --- /dev/null +++ b/packages/store-migrate/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": ["../../tsconfig.packages.json", "../tsconfig.paths.json"], + "compilerOptions": { + "types": ["node"], + "rootDir": "../..", + "noEmit": true + }, + "include": ["src"], + "exclude": ["dist", "node_modules"] +} diff --git a/src/util/index.ts b/src/util/index.ts index ba848fe..abd7d60 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -1,4 +1,11 @@ -export { bytesToHex, hexToBytes, toBytesView, uint8Equal } from '@util/bytes' +export { + base64ToBytes, + bytesToBase64, + bytesToHex, + hexToBytes, + toBytesView, + uint8Equal +} from '@util/bytes' export { asBytes, asNumber,