Skip to content

node:crypto: support the chacha20-poly1305 cipher - #33305

Open
robobun wants to merge 3 commits into
mainfrom
farm/998dc524/chacha20-poly1305
Open

node:crypto: support the chacha20-poly1305 cipher#33305
robobun wants to merge 3 commits into
mainfrom
farm/998dc524/chacha20-poly1305

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Fixes #8072. Depends on oven-sh/boringssl#9, which this pins.

Repro

crypto.createCipheriv("chacha20-poly1305", key32, nonce12, { authTagLength: 16 });
// node: works   bun: ERR_CRYPTO_UNKNOWN_CIPHER: Unknown cipher

chacha20-poly1305 is also absent from crypto.getCiphers(), so libraries that pick an AEAD by probing that list never select it on Bun.

Cause

BoringSSL only exposes ChaCha20-Poly1305 through the one-shot EVP_AEAD interface, never as an EVP_CIPHER. Cipher::FromName() goes through EVP_get_cipherbyname(), which has no entry for it, so the lookup returns null and constructCipher throws. getCiphers() walks EVP_CIPHER_do_all_sorted() and has the same gap.

The same is true of aes-*-ccm, aes-*-ocb and the aria-* family, which BoringSSL does not implement at all. ChaCha20-Poly1305 is the one where the primitives are already there; this PR is only about that cipher.

Fix

oven-sh/boringssl#9 adds EVP_chacha20_poly1305(), an EVP_CIPHER implementing RFC 8439 over CRYPTO_chacha_20 and CRYPTO_poly1305_*, registered with EVP_get_cipherbyname, EVP_get_cipherbynid and EVP_CIPHER_do_all_sorted. This PR pins that commit.

Nothing else was needed: Cipher::isSupportedAuthenticatedMode(), CipherCtxPointer::isChaCha20Poly1305() and the 16-byte default authTagLength in initAuthenticated() were all already written for this cipher and start working once the lookup succeeds.

Two places where Bun now behaves differently from Node, both deliberate, both matching what Node and Bun already do for aes-256-gcm:

aes-256-gcm (node & bun) chacha20-poly1305 (node) chacha20-poly1305 (this PR)
setAAD() after update() throws silently accepted, tag comes out wrong throws
decipher final() with no setAuthTag() throws returns unauthenticated plaintext throws

Verification

$ bun bd test test/js/node/crypto/crypto.test.ts
 395 pass
 0 fail

$ USE_SYSTEM_BUN=1 bun test test/js/node/crypto/crypto.test.ts -t chacha20-poly1305
 0 pass
 26 fail     # "Unknown cipher"

The tests are the RFC 8439 section 2.8.2 known-answer vector, round trips, streaming in 1/7/16/31/63/64/65/127/128-byte chunks (ChaCha20's block is 64 bytes, so chunks straddling the boundary are the interesting case), update()/final() output lengths, tampered tag/ciphertext/AAD, truncated authTagLength 1 through 16, and the nonce, key and tag length checks.

BoringSSL's own crypto_test is 1819 passed / 0 failed on the pinned commit; cipher_tests.txt there runs the same vectors across every chunk size, in-place, and with a mid-operation EVP_CIPHER_CTX_copy.

Note on the automated fail-before check

The gate re-derives the proof with git stash push -- src/ packages/. This diff touches neither, because the fix lives in the pinned BoringSSL commit, so that check cannot reproduce the failure. The USE_SYSTEM_BUN=1 run above is the fail-before evidence.

Supersedes #25837, which took the same approach through patches/ rather than the fork, and was written against an older evp_cipher_st layout (BoringSSL has since split cipher into cipher_update/cipher_final/update_aad, so its positional initializer would have bound the wrong callbacks).

BORINGSSL_COMMIT currently points at the head of oven-sh/boringssl#9; it needs one more bump to the merge commit once that lands, along with the matching hash in test/js/node/process/process.test.js.

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
@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:28 AM PT - Jul 3rd, 2026

@robobun, your commit e580b8d is building: #68202

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR updates the pinned BoringSSL commit hash used by the build configuration, adjusts the matching process.versions.boringssl test expectation, and adds a chacha20-poly1305 crypto test suite covering metadata, vectors, streaming, authentication, truncation, and invalid-input handling.

Changes

BoringSSL Pin Update and Crypto Test Coverage

Layer / File(s) Summary
BoringSSL pin and version expectation
scripts/build/deps/boringssl.ts, test/js/node/process/process.test.js
Changes the pinned BORINGSSL_COMMIT value and updates the expected process.versions.boringssl hash to match it.
chacha20-poly1305 test suite
test/js/node/crypto/crypto.test.ts
Adds tests for getCiphers()/getCipherInfo(), RFC 8439 encrypt/decrypt vectors, chunked output equivalence, empty plaintext, AAD handling, tampering rejection, authTagLength truncation, and invalid parameter/state cases.

Possibly related PRs

  • oven-sh/bun#29323: Also updates BORINGSSL_COMMIT in scripts/build/deps/boringssl.ts and the matching process.versions.boringssl expectation.
  • oven-sh/bun#32521: Also changes the same BoringSSL pin and process.versions.boringssl test value.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: adding chacha20-poly1305 support to node:crypto.
Description check ✅ Passed The description is directly related to the changeset and explains the cipher support fix, BoringSSL pin, and verification.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. feat(crypto): add ChaCha20-Poly1305 cipher support #25837 - Also adds ChaCha20-Poly1305 cipher support to node:crypto, fixing the same issue Support ChaCha20-Poly1305 in node:crypto #8072

🤖 Generated with Claude Code

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Correct that #25837 targets the same issue, and the PR description here says it supersedes it rather than duplicating it. The two take the same idea in different places, and #25837 does not build as written:

  • It lands the cipher as a patches/boringssl/*.patch file, but never adds patches: [...] to the BoringSSL Dependency, so the patch is never applied.
  • Its new crypto/cipher/e_chacha20poly1305_cipher.cc is registered only in BoringSSL's gen/sources.cmake. Bun's build lists BoringSSL's sources explicitly in scripts/build/deps/boringssl.ts, so the file would never be compiled.
  • Its EVP_CIPHER is a positional initializer against the old evp_cipher_st layout. BoringSSL has since split the single cipher callback into cipher_update, cipher_final and update_aad, so it would bind cipher to cipher_update and point cipher_final/update_aad at cleanup/ctrl.
  • chacha20_poly1305_init_key copies 12 bytes out of the IV regardless of the configured nonce length, reading past a shorter one.

This PR puts EVP_chacha20_poly1305() in the fork instead (oven-sh/boringssl#9), at a maintainer's request, so the patch lives next to the EVP_CIPHER machinery it depends on and BoringSSL's own cipher_tests.txt harness can exercise it. I left the same note on #25837.

@jtdowney, credit where it's due for the groundwork.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Bun-side diff is small and the tests look thorough, but the actual cipher implementation lives in oven-sh/boringssl#9 (which this pins while still unmerged), and it deliberately diverges from Node's chacha20-poly1305 semantics in two places — worth a human sign-off on both the fork crypto code and the compat trade-off.

Extended reasoning...

Overview

This PR enables chacha20-poly1305 in node:crypto by bumping BORINGSSL_COMMIT in scripts/build/deps/boringssl.ts to a commit on the oven-sh/boringssl fork that adds EVP_chacha20_poly1305() as a streaming EVP_CIPHER. The remaining changes are test-only: a ~200-line RFC 8439 known-answer / streaming / tamper suite in test/js/node/crypto/crypto.test.ts, and the matching hash update in process.test.js. No Bun runtime source changes — the existing Cipher::isSupportedAuthenticatedMode() / isChaCha20Poly1305() paths light up once the lookup succeeds.

Security risks

The substantive change is a new AEAD primitive implemented in vendored C++ (the fork PR), not visible in this diff. A bug in the streaming Poly1305 state machine, block-boundary buffering, or tag comparison could yield silently wrong tags or accept tampered ciphertext. The test suite here is good (KAT vector, chunk-boundary matrix, tamper-tag/ciphertext/AAD, truncated tags, missing-tag rejection), but it can't substitute for a review of the actual EVP_CIPHER callbacks in the fork.

Level of scrutiny

High. This is security-sensitive crypto code plus a vendored-dependency bump — both explicitly flagged in the repo's review guidelines as needing human review. Additionally, the description notes BORINGSSL_COMMIT currently points at the head of an unmerged fork PR and will need another bump once oven-sh/boringssl#9 lands, so a maintainer needs to sequence the merges.

Other factors

The PR intentionally diverges from Node.js in two places (throwing on setAAD() after update(), and on final() without setAuthTag(), where Node returns unauthenticated plaintext). The rationale — matching Bun's and Node's own aes-256-gcm behavior and refusing to release unauthenticated data — is defensible, but it's an API-compat decision that should be a maintainer's call, not an auto-approval.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed on all three points, and the sequencing is the right thing to flag. A few notes to make the review cheaper.

Review map for oven-sh/boringssl#9

The whole implementation is crypto/cipher/e_chacha20poly1305.cc, appended below the existing EVP_AEAD. Three parts are non-obvious and are where a bug would hide:

  1. cipher_chacha20_poly1305_update carries the unconsumed tail of the current ChaCha20 block across calls (keystream / keystream_used), because EVP_EncryptUpdate can split the message anywhere but CRYPTO_chacha_20 only takes a block counter. Three phases: spend the leftover, run whole blocks straight through, buffer a fresh block for the remainder. Poly1305 covers the ciphertext, so it hashes in before writing out when decrypting, since they may alias.

  2. EVP_CTRL_COPY. poly1305_state is a flat uint8_t[512] and CRYPTO_poly1305_* keeps its working state at a 64-byte-aligned offset inside it (poly1305_aligned_state). EVP_CIPHER_CTX_copy() byte-copies cipher_data, so the state lands at the wrong offset whenever the destination allocation has a different alignment. The ctrl arm moves it back. Deleting that arm makes crypto_test abort mid-run on the copy=true harness rows, which is how it was found.

  3. cipher_chacha20_poly1305_final leaves the error queue clean on a tag mismatch, matching aes_gcm_cipher_final. Callers rely on this: Node only reports "Unsupported state or unable to authenticate data" when ERR_get_error() comes back empty. Pushing CIPHER_R_BAD_DECRYPT there surfaced the raw OpenSSL string instead, which is what the three tamper tests here caught.

The vectors in cipher_tests.txt are RFC 8439 section 2.8.2 plus ten generated with OpenSSL, and the existing harness runs each across all 17 chunk sizes, in place and out of place, with and without a mid-operation EVP_CIPHER_CTX_copy.

On the two divergences

One of them is not a new opinion. The missing-setAuthTag() rejection is bun's existing code on main:

if (cipher->m_kind == CipherKind::Decipher && cipher->m_ctx.isChaCha20Poly1305() && cipher->m_authTagState != AuthTagState::AuthTagPassedToOpenSSL) {
    throwCryptoErrorWithAuth(lexicalGlobalObject, scope);
    return {};
}

That guard is a port of the equivalent check in CipherBase::Final, it predates this PR, and it fires regardless of what BoringSSL does. So Bun would throw here even against a byte-for-byte Node-identical cipher. Node v26.3.0 empirically does not throw, and returns the plaintext.

That leaves exactly one decision: setAAD() after update(). Bun throws ERR_CRYPTO_INVALID_STATE; Node accepts it and produces a tag that will not verify. Bun's own aes-256-gcm already throws, because BoringSSL's aes_gcm_update_aad rejects additional data once ciphertext has been hashed. If you would rather match Node exactly, it is deleting the if (c->aad_done) arm of cipher_chacha20_poly1305_update_aad in the fork PR, one hunk, and the matching test here.

Sequencing

oven-sh/boringssl#9 merges first. Then BORINGSSL_COMMIT in scripts/build/deps/boringssl.ts and the matching hash in test/js/node/process/process.test.js both move to the merge commit. Happy to push that bump whenever #9 lands.

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

The red X on build 68202 is infrastructure, not the diff. 285 of 286 jobs passed; the one failure is a shard of darwin 26 aarch64 - test-bun that never ran a test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The other shard of that same lane passed, as did darwin 14 aarch64, every Linux and Alpine aarch64 lane, and both Windows aarch64 lanes, so macOS and arm64 are both covered by green runs. The only other red in the build is a complex-workspace.test.ts install flake on x64-asan that Buildkite itself annotated as flaky and that passed on retry.

Not re-rolling, since the failed shard produced no signal to re-roll for. Happy to push a retrigger if you would rather see a fully green board before merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support ChaCha20-Poly1305 in node:crypto

1 participant