From acbc60d47f3ceebe8755b4fab1fb008ac5ebe780 Mon Sep 17 00:00:00 2001 From: holmes Date: Wed, 9 Sep 2026 06:29:17 -0700 Subject: [PATCH 1/4] Upgrade MessagePack serialization for Node 24 --- .../auth-provider-automerge-repo/package.json | 2 +- packages/auth/package.json | 2 +- packages/crdx/package.json | 2 +- packages/crypto/package.json | 2 +- pnpm-lock.yaml | 24 +++++++++---------- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/auth-provider-automerge-repo/package.json b/packages/auth-provider-automerge-repo/package.json index 4c423e0d1..bfb1882bd 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.10.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 3d7aa16ab..3a06bbf6c 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.10.2", + "msgpackr": "1.11.8", "xstate": "^5.9.1" }, "publishConfig": { diff --git a/packages/crdx/package.json b/packages/crdx/package.json index ef5a65d36..74941ecb9 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.10.2" + "msgpackr": "1.11.8" }, "publishConfig": { "access": "public" diff --git a/packages/crypto/package.json b/packages/crypto/package.json index 88d81c022..966a15dac 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.10.2" + "msgpackr": "1.11.8" }, "publishConfig": { "access": "public" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f32c6820a..1b07e5131 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.10.2 - version: 1.10.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.10.2 - version: 1.10.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.10.2 - version: 1.10.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.10.2 - version: 1.10.2 + specifier: 1.11.8 + version: 1.11.8 packages/shared: dependencies: @@ -6978,8 +6978,8 @@ packages: resolution: {integrity: sha512-SdzXp4kD/Qf8agZ9+iTu6eql0m3kWm1A2y1hkpTeVNENutaB0BwHlSvAIaMxwntmRUAUjon2V4L8Z/njd0Ct8A==} hasBin: true - msgpackr@1.10.2: - resolution: {integrity: sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==} + msgpackr@1.11.8: + resolution: {integrity: sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA==} multicast-dns@7.2.5: resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} @@ -11904,7 +11904,7 @@ snapshots: express: 4.19.2 express-ws: 5.0.2(express@4.19.2) isomorphic-ws: 5.0.0(ws@8.17.0) - msgpackr: 1.10.2 + msgpackr: 1.11.8 ws: 8.17.0 transitivePeerDependencies: - bufferutil @@ -17911,7 +17911,7 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.2 optional: true - msgpackr@1.10.2: + msgpackr@1.11.8: optionalDependencies: msgpackr-extract: 3.0.2 From 2acae46677b6be7c95a2fb3de23f211877985629 Mon Sep 17 00:00:00 2001 From: holmes Date: Wed, 9 Sep 2026 06:29:59 -0700 Subject: [PATCH 2/4] test(crypto): exercise long Unicode payloads on Node 24 --- packages/crypto/src/test/longPayload.test.ts | 104 +++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 packages/crypto/src/test/longPayload.test.ts diff --git a/packages/crypto/src/test/longPayload.test.ts b/packages/crypto/src/test/longPayload.test.ts new file mode 100644 index 000000000..bd8cb7263 --- /dev/null +++ b/packages/crypto/src/test/longPayload.test.ts @@ -0,0 +1,104 @@ +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 +// ERR_OUT_OF_RANGE 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 signature = signatures.sign(payload, signer.secretKey) + expect(signatures.sign(makePayload(), signer.secretKey)).toBe(signature) + expect(signatures.verify({ payload, signature, publicKey: signer.publicKey })).toBe(true) + 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 })).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() + } + ) +}) From 001a091610e144f6304ea3714c0074ebf8c7d701 Mon Sep 17 00:00:00 2001 From: holmes Date: Wed, 9 Sep 2026 06:31:13 -0700 Subject: [PATCH 3/4] docs(crypto): describe the Node 24 serializer RangeError --- packages/crypto/src/test/longPayload.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/crypto/src/test/longPayload.test.ts b/packages/crypto/src/test/longPayload.test.ts index bd8cb7263..cef9a9aae 100644 --- a/packages/crypto/src/test/longPayload.test.ts +++ b/packages/crypto/src/test/longPayload.test.ts @@ -5,7 +5,7 @@ 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 -// ERR_OUT_OF_RANGE with msgpackr 1.10.2 on Node 24. These are public test values. +// 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', From d3fc6d2ca0a1bdd5852470584153b9a716427796 Mon Sep 17 00:00:00 2001 From: holmes Date: Wed, 9 Sep 2026 10:42:29 -0700 Subject: [PATCH 4/4] Use the pinned lockfile for Node 20 and 24 CI --- .github/workflows/tests.yaml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 83799825a..cf6387848 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -9,24 +9,28 @@ 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@v3 + - uses: actions/setup-node@v6 with: - node-version: '18.x' + node-version: ${{ matrix.node }} registry-url: 'https://registry.npmjs.org' - - uses: pnpm/action-setup@v2 + - uses: pnpm/action-setup@v5 with: - version: 8 + version: 10.6.0 run_install: false - - uses: actions/checkout@v3 + - uses: actions/checkout@v6 with: fetch-depth: 0 ref: ${{ github.ref }} - name: Install and build run: | - pnpm install + pnpm install --frozen-lockfile pnpm build - name: Run tests - run: pnpm test + run: pnpm test:run