diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index a3390841..cf638784 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,18 +9,22 @@ on: - main jobs: run-tests: - name: Run tests + name: Run tests (Node ${{ matrix.node }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: [20, 24] steps: - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: - node-version: '20.x' + node-version: ${{ matrix.node }} registry-url: 'https://registry.npmjs.org' - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@v5 with: - version: 10 + version: 10.6.0 run_install: false - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.ref }} @@ -29,4 +33,4 @@ jobs: pnpm install --frozen-lockfile pnpm build - name: Run tests - run: pnpm test + run: pnpm test:run diff --git a/packages/auth-provider-automerge-repo/package.json b/packages/auth-provider-automerge-repo/package.json index d5aecd8a..bfb1882b 100644 --- a/packages/auth-provider-automerge-repo/package.json +++ b/packages/auth-provider-automerge-repo/package.json @@ -26,7 +26,7 @@ "@localfirst/auth": "workspace:*", "@localfirst/crypto": "workspace:*", "@localfirst/shared": "workspace:*", - "msgpackr": "1.11.2" + "msgpackr": "1.11.8" }, "devDependencies": { "@automerge/automerge-repo-network-messagechannel": "^1.1.2", diff --git a/packages/auth/package.json b/packages/auth/package.json index 5309fc53..3a06bbf6 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -31,7 +31,7 @@ "@localfirst/shared": "workspace:*", "@paralleldrive/cuid2": "^2.2.2", "lodash-es": "^4.17.21", - "msgpackr": "1.11.2", + "msgpackr": "1.11.8", "xstate": "^5.9.1" }, "publishConfig": { diff --git a/packages/crdx/package.json b/packages/crdx/package.json index 9f3e0121..74941ecb 100644 --- a/packages/crdx/package.json +++ b/packages/crdx/package.json @@ -26,7 +26,7 @@ "@paralleldrive/cuid2": "^2.2.2", "@types/lodash-es": "^4.17.12", "lodash-es": "^4.17.21", - "msgpackr": "1.11.2" + "msgpackr": "1.11.8" }, "publishConfig": { "access": "public" diff --git a/packages/crypto/package.json b/packages/crypto/package.json index a8ac808a..966a15da 100644 --- a/packages/crypto/package.json +++ b/packages/crypto/package.json @@ -28,7 +28,7 @@ "bs58": "^5.0.0", "libsodium-wrappers-sumo": "^0.7.13", "lodash-es": "^4.17.21", - "msgpackr": "1.11.2" + "msgpackr": "1.11.8" }, "publishConfig": { "access": "public" diff --git a/packages/crypto/src/test/longPayload.test.ts b/packages/crypto/src/test/longPayload.test.ts new file mode 100644 index 00000000..24251fd5 --- /dev/null +++ b/packages/crypto/src/test/longPayload.test.ts @@ -0,0 +1,115 @@ +import { Buffer } from 'node:buffer' +import { pack, unpack } from 'msgpackr' +import { describe, expect, test } from 'vitest' +import { asymmetric, hash, signatures, symmetric } from '../index.js' +import { keyToBytes } from '../util/index.js' + +// Long UTF-8 strings exercise msgpackr's Buffer write path, which threw +// a RangeError with msgpackr 1.10.2 on Node 24. These are public test values. +const text = 'Quiet 🌱 — ключи — 鍵 '.repeat(12) +const makePayload = () => ({ + type: 'long-unicode-payload', + text, + nested: { count: 42, enabled: true, empty: null, previous: undefined }, + history: [{ text, previous: undefined }], + bytes: Buffer.from([0, 1, 127, 128, 255]), +}) + +type TestPayload = ReturnType + +function expectPayload(actual: TestPayload) { + // libsodium returns Uint8Array, while Node's MessagePack reader can return + // Buffer. Both must preserve every byte and all nested/undefined fields. + expect(actual.bytes).toBeInstanceOf(Uint8Array) + expect({ ...actual, bytes: Array.from(actual.bytes) }).toStrictEqual({ + ...makePayload(), + bytes: Array.from(makePayload().bytes), + }) +} + +describe('long Unicode payloads on current Node', () => { + test('serializes nested values, undefined fields and binary bytes without truncation', () => { + expect(Buffer.byteLength(text, 'utf8')).toBeGreaterThan(64) + for (let index = 0; index < 3; index++) { + // Repeated packing also exercises writes at different offsets in the + // serializer's reusable buffer, rather than only its first allocation. + expectPayload(unpack(pack(makePayload()))) + } + }) + + test('hashes every field deterministically and distinguishes changed payloads', () => { + const purpose = 'public-node24-regression-hash' + const digest = hash(purpose, makePayload()) + expect(keyToBytes(digest)).toHaveLength(32) + expect(hash(purpose, makePayload())).toBe(digest) + expect(hash(purpose, { ...makePayload(), text: text + '!' })).not.toBe(digest) + expect(hash('another-node24-hash-purpose', makePayload())).not.toBe(digest) + }) + + test('signs the full payload and rejects text, nested-field and binary tampering', () => { + const signer = signatures.keyPair('public-node24-regression-signer') + const payload = makePayload() + const context = 'test/node24-long-unicode' + const signature = signatures.sign(payload, signer.secretKey, context) + expect(signatures.sign(makePayload(), signer.secretKey, context)).toBe(signature) + expect(signatures.verify({ payload, signature, publicKey: signer.publicKey, context })).toBe( + true + ) + expect( + signatures.verify({ + payload, + signature, + publicKey: signer.publicKey, + context: context + '/other', + }) + ).toBe(false) + const changed = [ + { ...makePayload(), text: text + '!' }, + { ...makePayload(), nested: { ...payload.nested, count: 43 } }, + { ...makePayload(), bytes: Buffer.from([0, 1, 127, 128, 254]) }, + ] + for (const tampered of changed) { + expect( + signatures.verify({ payload: tampered, signature, publicKey: signer.publicKey, context }) + ).toBe(false) + } + }) + + test('symmetrically encrypts and decrypts the payload and rejects modified ciphertext', () => { + const password = 'public-node24-regression-password' + const cipher = symmetric.encrypt(makePayload(), password) + expectPayload(symmetric.decrypt(cipher, password)) + expect(() => symmetric.decrypt(cipher, 'a-different-test-password')).toThrow() + + const cipherBytes = symmetric.encryptBytes(makePayload(), password) + expectPayload(symmetric.decryptBytes(cipherBytes, password)) + const modified = unpack(cipherBytes) + modified.message[0] ^= 1 + expect(() => symmetric.decryptBytes(pack(modified), password)).toThrow() + }) + + test.each(['provided', 'ephemeral'] as const)( + 'asymmetric round trip with a %s sender key', + mode => { + const recipient = asymmetric.keyPair('public-node24-regression-recipient') + const sender = asymmetric.keyPair('public-node24-regression-sender') + const wrongRecipient = asymmetric.keyPair('public-node24-regression-wrong-recipient') + const cipher = asymmetric.encrypt({ + secret: makePayload(), + recipientPublicKey: recipient.publicKey, + senderSecretKey: mode === 'provided' ? sender.secretKey : undefined, + }) + const senderPublicKey = mode === 'provided' ? sender.publicKey : undefined + expectPayload( + asymmetric.decrypt({ cipher, senderPublicKey, recipientSecretKey: recipient.secretKey }) + ) + expect(() => + asymmetric.decrypt({ + cipher, + senderPublicKey, + recipientSecretKey: wrongRecipient.secretKey, + }) + ).toThrow() + } + ) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04347e40..61a339ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -474,8 +474,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 msgpackr: - specifier: 1.11.2 - version: 1.11.2 + specifier: 1.11.8 + version: 1.11.8 xstate: specifier: ^5.9.1 version: 5.13.0 @@ -498,8 +498,8 @@ importers: specifier: workspace:* version: link:../shared msgpackr: - specifier: 1.11.2 - version: 1.11.2 + specifier: 1.11.8 + version: 1.11.8 devDependencies: '@automerge/automerge-repo-network-messagechannel': specifier: ^1.1.2 @@ -581,8 +581,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 msgpackr: - specifier: 1.11.2 - version: 1.11.2 + specifier: 1.11.8 + version: 1.11.8 packages/crypto: dependencies: @@ -602,8 +602,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 msgpackr: - specifier: 1.11.2 - version: 1.11.2 + specifier: 1.11.8 + version: 1.11.8 packages/shared: dependencies: @@ -6981,6 +6981,9 @@ packages: msgpackr@1.10.2: resolution: {integrity: sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==} + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} + msgpackr@1.11.2: resolution: {integrity: sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==} @@ -17918,6 +17921,10 @@ snapshots: optionalDependencies: msgpackr-extract: 3.0.2 + msgpackr@1.11.8: + optionalDependencies: + msgpackr-extract: 3.0.2 + msgpackr@1.11.2: optionalDependencies: msgpackr-extract: 3.0.2