From 6df3e997efbb51a64804bb8cfad0fe51c8f1d13b Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 3 Jul 2026 09:58:43 +0000 Subject: [PATCH 1/3] node:crypto: support the chacha20-poly1305 cipher BoringSSL only exposes ChaCha20-Poly1305 through the one-shot EVP_AEAD interface, never as an EVP_CIPHER, so Cipher::FromName() could not find it and createCipheriv("chacha20-poly1305", ...) threw ERR_CRYPTO_UNKNOWN_CIPHER. getCiphers() left it out for the same reason. Pin the BoringSSL commit that adds EVP_chacha20_poly1305(). Everything on this side was already in place: isSupportedAuthenticatedMode(), isChaCha20Poly1305() and the 16-byte default authTagLength in initAuthenticated() all handle the cipher once the lookup succeeds. Fixes #8072 --- scripts/build/deps/boringssl.ts | 2 +- test/js/node/crypto/crypto.test.ts | 201 +++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/scripts/build/deps/boringssl.ts b/scripts/build/deps/boringssl.ts index 4b15380e257e..7f840e1cf782 100644 --- a/scripts/build/deps/boringssl.ts +++ b/scripts/build/deps/boringssl.ts @@ -24,7 +24,7 @@ import { quote } from "../shell.ts"; import type { Dependency, DirectBuild } from "../source.ts"; import { depSourceDir } from "../source.ts"; -const BORINGSSL_COMMIT = "1a41b9025c2c0a37edd07ff10f6944f03e028522"; +const BORINGSSL_COMMIT = "916aae51e623d730acb3466a8ffd623e3ee72d72"; export const boringssl: Dependency = { name: "boringssl", diff --git a/test/js/node/crypto/crypto.test.ts b/test/js/node/crypto/crypto.test.ts index 16b4be3a91a9..060a96a509dd 100644 --- a/test/js/node/crypto/crypto.test.ts +++ b/test/js/node/crypto/crypto.test.ts @@ -160,6 +160,207 @@ describe("crypto.getCurves", () => { }); }); +describe("chacha20-poly1305", () => { + // RFC 8439, section 2.8.2. + const key = Buffer.from("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", "hex"); + const iv = Buffer.from("070000004041424344454647", "hex"); + const aad = Buffer.from("50515253c0c1c2c3c4c5c6c7", "hex"); + const plaintext = Buffer.from("Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."); + const ciphertext = Buffer.from( + "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6" + + "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36" + + "92ddbd7f2d778b8c9803aee328091b58fab324e4fad675945585808b4831d7bc" + + "3ff4def08e4b7a9de576d26586cec64b6116", + "hex", + ); + const authTag = Buffer.from("1ae10b594f09e26a7e902ecbd0600691", "hex"); + + it("is listed by getCiphers()", () => { + expect(crypto.getCiphers()).toContain("chacha20-poly1305"); + }); + + it("is described by getCipherInfo()", () => { + const { mode, name, keyLength, ivLength } = crypto.getCipherInfo("chacha20-poly1305")!; + expect({ mode, name, keyLength, ivLength }).toEqual({ + mode: "stream", + name: "chacha20-poly1305", + keyLength: 32, + ivLength: 12, + }); + }); + + it("encrypts the RFC 8439 vector", () => { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + cipher.setAAD(aad); + const out = Buffer.concat([cipher.update(plaintext), cipher.final()]); + expect(out.toString("hex")).toBe(ciphertext.toString("hex")); + expect(cipher.getAuthTag().toString("hex")).toBe(authTag.toString("hex")); + }); + + it("decrypts the RFC 8439 vector", () => { + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(aad); + decipher.setAuthTag(authTag); + const out = Buffer.concat([decipher.update(ciphertext), decipher.final()]); + expect(out.toString("utf8")).toBe(plaintext.toString("utf8")); + }); + + it("defaults authTagLength to 16 bytes", () => { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv); + cipher.update(plaintext); + cipher.final(); + expect(cipher.getAuthTag().length).toBe(16); + }); + + // ChaCha20 keeps 64-byte keystream blocks, so chunks that straddle a block + // boundary are the interesting case. + for (const chunkSize of [1, 7, 16, 31, 63, 64, 65, 127, 128]) { + it(`produces the same output in ${chunkSize}-byte chunks`, () => { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + cipher.setAAD(aad); + const chunks: Buffer[] = []; + for (let i = 0; i < plaintext.length; i += chunkSize) { + const chunk = plaintext.subarray(i, i + chunkSize); + const out = cipher.update(chunk); + // A stream cipher emits exactly as many bytes as it is fed. + expect(out.length).toBe(chunk.length); + chunks.push(out); + } + const final = cipher.final(); + expect(final.length).toBe(0); + chunks.push(final); + expect(Buffer.concat(chunks).toString("hex")).toBe(ciphertext.toString("hex")); + expect(cipher.getAuthTag().toString("hex")).toBe(authTag.toString("hex")); + + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(aad); + decipher.setAuthTag(authTag); + const plain: Buffer[] = []; + for (let i = 0; i < ciphertext.length; i += chunkSize) { + plain.push(decipher.update(ciphertext.subarray(i, i + chunkSize))); + } + plain.push(decipher.final()); + expect(Buffer.concat(plain).toString("utf8")).toBe(plaintext.toString("utf8")); + }); + } + + it("round-trips an empty message", () => { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv); + expect(cipher.final().length).toBe(0); + const tag = cipher.getAuthTag(); + + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv); + decipher.setAuthTag(tag); + expect(decipher.final().length).toBe(0); + }); + + it("round-trips additional data that is not a multiple of 16 bytes", () => { + const oddAad = Buffer.from("0102030405", "hex"); + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv); + cipher.setAAD(oddAad); + const out = Buffer.concat([cipher.update(plaintext), cipher.final()]); + // The AAD is not encrypted, so only the tag changes. + expect(out.toString("hex")).toBe(ciphertext.toString("hex")); + + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv); + decipher.setAAD(oddAad); + decipher.setAuthTag(cipher.getAuthTag()); + expect(Buffer.concat([decipher.update(out), decipher.final()]).toString("utf8")).toBe(plaintext.toString("utf8")); + }); + + it("rejects a corrupted authentication tag", () => { + const tampered = Buffer.from(authTag); + tampered[0] ^= 1; + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(aad); + decipher.setAuthTag(tampered); + decipher.update(ciphertext); + expect(() => decipher.final()).toThrow("Unsupported state or unable to authenticate data"); + }); + + it("rejects corrupted ciphertext", () => { + const tampered = Buffer.from(ciphertext); + tampered[0] ^= 1; + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(aad); + decipher.setAuthTag(authTag); + decipher.update(tampered); + expect(() => decipher.final()).toThrow("Unsupported state or unable to authenticate data"); + }); + + it("rejects corrupted additional data", () => { + const tampered = Buffer.from(aad); + tampered[0] ^= 1; + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(tampered); + decipher.setAuthTag(authTag); + decipher.update(ciphertext); + expect(() => decipher.final()).toThrow("Unsupported state or unable to authenticate data"); + }); + + // Node releases unauthenticated plaintext here for this cipher, unlike every + // other AEAD it exposes. Fail instead. + it("rejects decryption without an authentication tag", () => { + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 16 }); + decipher.setAAD(aad); + decipher.update(ciphertext); + expect(() => decipher.final()).toThrow("Unsupported state or unable to authenticate data"); + }); + + it("honours a truncated authTagLength", () => { + for (let authTagLength = 1; authTagLength <= 16; authTagLength++) { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv, { authTagLength }); + cipher.setAAD(aad); + const out = Buffer.concat([cipher.update(plaintext), cipher.final()]); + expect(out.toString("hex")).toBe(ciphertext.toString("hex")); + const tag = cipher.getAuthTag(); + expect(tag.toString("hex")).toBe(authTag.subarray(0, authTagLength).toString("hex")); + + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength }); + decipher.setAAD(aad); + decipher.setAuthTag(tag); + expect(Buffer.concat([decipher.update(out), decipher.final()]).toString("utf8")).toBe( + plaintext.toString("utf8"), + ); + } + }); + + it("rejects an authentication tag of the wrong length", () => { + const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength: 12 }); + expect(() => decipher.setAuthTag(authTag)).toThrow("Invalid authentication tag length: 16"); + }); + + it("rejects additional data after the first update()", () => { + const cipher = crypto.createCipheriv("chacha20-poly1305", key, iv); + cipher.update(plaintext); + expect(() => cipher.setAAD(aad)).toThrow(expect.objectContaining({ code: "ERR_CRYPTO_INVALID_STATE" })); + }); + + it("requires a 96-bit nonce", () => { + for (const ivLength of [0, 1, 8, 11, 13, 16, 24]) { + expect(() => crypto.createCipheriv("chacha20-poly1305", key, Buffer.alloc(ivLength))).toThrow( + expect.objectContaining({ code: "ERR_CRYPTO_INVALID_IV" }), + ); + } + }); + + it("requires a 256-bit key", () => { + for (const keyLength of [16, 24, 31, 33]) { + expect(() => crypto.createCipheriv("chacha20-poly1305", Buffer.alloc(keyLength), iv)).toThrow( + expect.objectContaining({ code: "ERR_CRYPTO_INVALID_KEYLEN" }), + ); + } + }); + + it("rejects an out-of-range authTagLength", () => { + for (const authTagLength of [0, 17, 32]) { + expect(() => crypto.createCipheriv("chacha20-poly1305", key, iv, { authTagLength })).toThrow( + `Invalid authentication tag length: ${authTagLength}`, + ); + } + }); +}); + describe("crypto", () => { for (let Hash of HashClasses) { for (let [input, label] of [ From b04e12ba6043fc9fa6cab56c9e97b64998e0fa4a Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 10:02:01 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- test/js/node/crypto/crypto.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/js/node/crypto/crypto.test.ts b/test/js/node/crypto/crypto.test.ts index 060a96a509dd..d42cccd93f42 100644 --- a/test/js/node/crypto/crypto.test.ts +++ b/test/js/node/crypto/crypto.test.ts @@ -165,7 +165,9 @@ describe("chacha20-poly1305", () => { const key = Buffer.from("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f", "hex"); const iv = Buffer.from("070000004041424344454647", "hex"); const aad = Buffer.from("50515253c0c1c2c3c4c5c6c7", "hex"); - const plaintext = Buffer.from("Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."); + const plaintext = Buffer.from( + "Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it.", + ); const ciphertext = Buffer.from( "d31a8d34648e60db7b86afbc53ef7ec2a4aded51296e08fea9e2b5a736ee62d6" + "3dbea45e8ca9671282fafb69da92728b1a71de0a9e060b2905d6a5b67ecd3b36" + @@ -319,9 +321,7 @@ describe("chacha20-poly1305", () => { const decipher = crypto.createDecipheriv("chacha20-poly1305", key, iv, { authTagLength }); decipher.setAAD(aad); decipher.setAuthTag(tag); - expect(Buffer.concat([decipher.update(out), decipher.final()]).toString("utf8")).toBe( - plaintext.toString("utf8"), - ); + expect(Buffer.concat([decipher.update(out), decipher.final()]).toString("utf8")).toBe(plaintext.toString("utf8")); } }); From e580b8df82a4cdd43255aa593196e94c6108dbc6 Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 3 Jul 2026 10:28:40 +0000 Subject: [PATCH 3/3] Update the pinned BoringSSL hash in process.versions --- test/js/node/process/process.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 481df9d6707d..ac717846a4cc 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -273,7 +273,7 @@ it("process.versions", () => { // These are the ACTUAL commits built into bun (not derived values, so // bumping a dep requires updating this test too). const expectedVersions = { - boringssl: "1a41b9025c2c0a37edd07ff10f6944f03e028522", + boringssl: "916aae51e623d730acb3466a8ffd623e3ee72d72", libarchive: "ded82291ab41d5e355831b96b0e1ff49e24d8939", mimalloc: "afb41757285694f832e7a2f164d35f5717457f96", picohttpparser: "066d2b1e9ab820703db0837a7255d92d30f0c9f5",