{{ message }}
Repository navigation
Conversation
Cipheriv and Decipheriv update() and final() each turned their own bytes
into a string with Buffer#toString(outputEncoding). The bytes one call
returns need not end on a base64 group or a character boundary, so the
usual Node.js pattern
let out = cipher.update(text, 'utf8', 'base64');
out += cipher.final('base64');
put '=' padding in the middle of the string whenever update() returned a
byte count that is not a multiple of 3. The result was not valid base64,
and decrypting it failed. Decrypting to 'utf8' turned a character split
between update() and final() into U+FFFD. Block modes hit this with a
single update(); stream modes such as GCM hit it across several update()
calls.
Node.js keeps one StringDecoder per instance for this, and so do these
classes now: the first update() or final() call that asks for a string
creates the decoder, update() writes to it and final() ends it. As in
Node.js, a later call with a different output encoding throws
ERR_INVALID_ARG_VALUE, since the bytes the decoder holds cannot be
emitted in another encoding. The encoding is checked with
getEncodingOps(), as Buffer#toString() does. That check now also runs
when a call returns no bytes (final() in GCM, for example), which
Buffer#toString() skipped for an empty buffer; Node.js rejects an
unknown encoding there too.
An output encoding of null or 'buffer' now returns a Buffer, as no
output encoding does and as in Node.js. Both used to reach
Buffer#toString(), which threw ERR_UNKNOWN_ENCODING when the call
returned bytes and returned '' when it returned none: final() in GCM,
CTR or ChaCha20-Poly1305, or a CBC update() with no complete block to
return. They are handled here because the decoder checks the encoding
even when a call returns no bytes, so those zero-byte calls would
otherwise start to throw. '' still means utf8, as in Buffer#toString();
Node.js returns a Buffer for it, and that difference is left as it was.
Because '' means utf8 here, mixing it with a string encoding other than
utf8 on one instance now throws the encoding-change error, also on a
call that returns no bytes. Before, each call returned its own string
('' when it had no bytes); Node.js does not throw there, as it treats ''
as no encoding.
Compatibility: no flag; the risk is accepted. When every call on an
instance passes the same output encoding, output changes only where a
call's bytes end inside a base64 group or a multi-byte character, and
there the concatenated result was invalid; hex, latin1 and ascii output
is unchanged. The two new errors and null and 'buffer' returning a
Buffer follow Node.js, except for the encoding-change error with '',
described above. For null and 'buffer', a call that threw now returns a
Buffer, and a zero-byte call returns an empty Buffer instead of '';
concatenating that onto a string gives the same result as before.
Mixing string and Buffer output on one instance loses data, as it does
in Node.js. After a call with base64, base64url, utf8 or utf16le
output, a later update() or final() without an output encoding (or
with null or 'buffer') returns only its own bytes as a Buffer, not the
bytes the decoder still holds. A later call with the encoding returns
those bytes out of order; if no such call follows, they are lost. For
base64 and base64url this used to work, because each call's string
carried all of its bytes: update() of 43 bytes of text with 'base64'
output, then final() with none, gave back all 48 aes-256-cbc ciphertext
bytes and now gives back 46. With aes-256-gcm, calling final() with no
encoding only to finish the operation now drops the last byte of the
same text. utf8 and utf16le output already lost a split character's
bytes before this change. Passing the same encoding to final() avoids
the loss, here and in Node.js.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Cipheriv/Decipherivupdate()andfinal()each turned their own bytes into a string withBuffer#toString(outputEncoding). The bytes one call returns need not end on a base64 group or a UTF-8 character boundary, so the usual Node.js pattern gives wrong output:The first string has
=in the middle and is not valid base64, so decrypting it fails. Decrypting to'utf8'turns a character split betweenupdate()andfinal()into U+FFFD ('€€€€€€'came back as'€€€€€���'). Block modes hit this with a singleupdate(); stream modes such as GCM hit it across severalupdate()calls.Change
As in Node.js (
getDecoder()inlib/internal/crypto/cipher.js), each instance now keeps oneStringDecoder, created by the first call that asks for a string.update()writes to it andfinal()ends it, so bytes held back from one call come out with the next. Without an output encoding, or withnullor'buffer', both return aBufferof that call's bytes, which does not include bytes the decoder holds (see the last bullet below).Behaviour changes, so you can judge whether this wants a compatibility flag:
ERR_INVALID_ARG_VALUE("cannot be changed from ..."), as in Node.js.'bogus', say) now throwsERR_UNKNOWN_ENCODINGeven when the call returns no bytes (final()in GCM, for example), as in Node.js. Before,Buffer#toString()returned''for an empty buffer without checking the encoding. Non-string falsy values such asfalse, which Node.js treats as no encoding, still throw here as before, now also on zero-byte calls.nulland'buffer'as the output encoding now return aBuffer, as no encoding does and as in Node.js. Before, they threwERR_UNKNOWN_ENCODINGwhen the call returned bytes, and returned''when it returned none:final()in GCM, CTR or ChaCha20-Poly1305, or a CBCupdate()with no complete block to return. They have to be handled here, because the decoder checks the encoding on every call and those zero-byte calls would otherwise start to throw. Concatenating the emptyBufferonto a string gives the same result as''did.''still means utf8, as inBuffer#toString(); Node.js returns aBufferfor it, and I left that difference alone. Because of it, mixing''with a string encoding other than utf8 on one instance now throws the encoding-change error, also on a call that returns no bytes (update(text, 'utf8', 'hex') + final('')in GCM, for example). Before, each call returned its own string; Node.js does not throw there. I can make''return aBufferas in Node.js if you prefer.Bufferoutput on one instance loses data, as it does in Node.js. After a call with base64, base64url, utf8 or utf16le output, a laterupdate()orfinal()without an output encoding (or withnullor'buffer') returns only its own bytes, not the bytes the decoder still holds. A later call with the encoding returns those bytes out of order; if none follows, they are lost. For base64 and base64url this used to work, because each call's string carried all of its bytes: with the 43-byte text above,update(text, 'utf8', 'base64')followed byfinal()gave back all 48 ciphertext bytes and now gives back 46, and with aes-256-gcm, callingfinal()without an encoding only to finish the operation now drops the last byte. utf8 and utf16le output already lost a split character's bytes before. Passing the same encoding tofinal()avoids the loss, here and in Node.js. hex, latin1 and ascii are not affected.I did not add a compatibility flag, although
src/node/README.mdmentions a "No Breaking Changes Without Compatibility Flags" policy. For the usual pattern the old concatenated output was invalid, and the changes match Node.js except for the''case above. Code that worked before and now behaves differently: changing the output encoding on one instance (now an error, as in Node.js, and also with'', unlike Node.js), an unknown encoding on a zero-byte call (now an error, as in Node.js), a zero-byte call withnullor'buffer'(an emptyBufferinstead of''), and the mixed string/Buffercase with base64 or base64url output (last bullet). I can put the change behind a flag if you prefer.Open PRs #7541 and #6923 change the
inputEncodingcheck in the same twoupdate()functions. They do not touch these lines and do not fix this bug, and both merge cleanly with this one. One thing to watch: #7541 removes theERR_INVALID_ARG_VALUEimport, whichgetDecoder()here uses, so whichever lands second needs to keep it.Tests
Five new cases in
src/workerd/api/node/tests/crypto_cipher-test.js:update()/final()(aes-256-cbc) and across twoupdate()calls (aes-256-gcm), each with a decrypt round tripupdate()/final()nulland'buffer'with aes-256-cbc: cipher and decipherupdate()calls that return no bytes, andfinal()calls that return bytesnulland'buffer'withfinal()of aes-256-gcm, aes-256-ctr and chacha20-poly1305, for both cipher and decipher, after string output fromupdate()All five fail on
mainand pass with this change, and they also pass on Node.js 26:Also passing locally on macOS arm64:
bazel test //src/workerd/api/node/tests/... //src/node/...(421 targets, including the@eslintones),@gc-stressforcrypto_cipher-testandstring-decoder-test, andtools/cross/format.py --checkon the changed files. Linux and Windows were not run.This PR was prepared with an AI coding agent (Claude Code). I am responsible for it and will answer review.
🤖 Generated with Claude Code