Authentication/Tag handling for AEAD (AES-GCM/OCB/ChaCha20-Poly1305)

MEDIUM
nodejs/node
Commit: aaa91515fcce
Affected: < 25.9.0
2026-04-05 10:24 UTC

Description

The commit refactors WebCrypto AEAD authentication tag handling for AES-GCM/OCB and ChaCha20-Poly1305. It removes ad-hoc extraction of the authentication tag from the input and standardizes tag handling across JavaScript and native code. This fixes potential vulnerabilities related to improper or inconsistent processing of authentication tags during decryption (and tag length usage), reducing the risk of AEAD bypass or tag misvalidation.

Commit Details

Author: Filip Skokan

Date: 2026-03-11 18:43 UTC

Message:

crypto: refactor WebCrypto AEAD algorithms auth tag handling PR-URL: https://github.com/nodejs/node/pull/62169 Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com> Reviewed-By: Colin Ihrig <cjihrig@gmail.com>

Triage Assessment

Vulnerability Type: Authentication/Tag handling for AEAD (GCM/OCB/ChaCha20-Poly1305)

Confidence: MEDIUM

Reasoning:

The commit refactors handling of authentication tags for AEAD modes (AES-GCM/OCB and ChaCha20-Poly1305) in WebCrypto, removing ad-hoc tag extraction from inputs and standardizing tag handling in both JS and native code. This directly affects input validation and correct authentication tag processing, which are critical for ensuring AEAD protections are enforced and not bypassed. The changes include stricter/tag-aware processing for decryption and adjustments to tag length usage, addressing potential security gaps related to tag mismanagement.

Verification Assessment

Vulnerability Type: Authentication/Tag handling for AEAD (AES-GCM/OCB/ChaCha20-Poly1305)

Confidence: MEDIUM

Affected Versions: < 25.9.0

Code Diff

diff --git a/lib/internal/crypto/aes.js b/lib/internal/crypto/aes.js index 0474060d394c99..c0765f75642189 100644 --- a/lib/internal/crypto/aes.js +++ b/lib/internal/crypto/aes.js @@ -1,12 +1,9 @@ 'use strict'; const { - ArrayBufferIsView, - ArrayBufferPrototypeSlice, ArrayFrom, ArrayPrototypePush, SafeSet, - TypedArrayPrototypeSlice, } = primordials; const { @@ -28,8 +25,6 @@ const { kKeyVariantAES_GCM_256, kKeyVariantAES_KW_256, kKeyVariantAES_OCB_256, - kWebCryptoCipherDecrypt, - kWebCryptoCipherEncrypt, } = internalBinding('crypto'); const { @@ -143,80 +138,33 @@ function asyncAesKwCipher(mode, key, data) { getVariant('AES-KW', key[kAlgorithm].length))); } -async function asyncAesGcmCipher(mode, key, data, algorithm) { +function asyncAesGcmCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; - const tagByteLength = tagLength / 8; - let tag; - switch (mode) { - case kWebCryptoCipherDecrypt: { - const slice = ArrayBufferIsView(data) ? - TypedArrayPrototypeSlice : ArrayBufferPrototypeSlice; - tag = slice(data, -tagByteLength); - - // Refs: https://www.w3.org/TR/WebCryptoAPI/#aes-gcm-operations - // - // > If *plaintext* has a length less than *tagLength* bits, then `throw` - // > an `OperationError`. - if (tagByteLength > tag.byteLength) { - throw lazyDOMException( - 'The provided data is too small.', - 'OperationError'); - } - data = slice(data, 0, -tagByteLength); - break; - } - case kWebCryptoCipherEncrypt: - tag = tagByteLength; - break; - } - - return await jobPromise(() => new AESCipherJob( + return jobPromise(() => new AESCipherJob( kCryptoJobAsync, mode, key[kKeyObject][kHandle], data, getVariant('AES-GCM', key[kAlgorithm].length), algorithm.iv, - tag, + tagByteLength, algorithm.additionalData)); } -async function asyncAesOcbCipher(mode, key, data, algorithm) { +function asyncAesOcbCipher(mode, key, data, algorithm) { const { tagLength = 128 } = algorithm; - const tagByteLength = tagLength / 8; - let tag; - switch (mode) { - case kWebCryptoCipherDecrypt: { - const slice = ArrayBufferIsView(data) ? - TypedArrayPrototypeSlice : ArrayBufferPrototypeSlice; - tag = slice(data, -tagByteLength); - - // Similar to GCM, OCB requires the tag to be present for decryption - if (tagByteLength > tag.byteLength) { - throw lazyDOMException( - 'The provided data is too small.', - 'OperationError'); - } - data = slice(data, 0, -tagByteLength); - break; - } - case kWebCryptoCipherEncrypt: - tag = tagByteLength; - break; - } - - return await jobPromise(() => new AESCipherJob( + return jobPromise(() => new AESCipherJob( kCryptoJobAsync, mode, key[kKeyObject][kHandle], data, getVariant('AES-OCB', key.algorithm.length), algorithm.iv, - tag, + tagByteLength, algorithm.additionalData)); } diff --git a/lib/internal/crypto/chacha20_poly1305.js b/lib/internal/crypto/chacha20_poly1305.js index 0979d7aaddbb61..a2b7c1fb04fb89 100644 --- a/lib/internal/crypto/chacha20_poly1305.js +++ b/lib/internal/crypto/chacha20_poly1305.js @@ -1,19 +1,14 @@ 'use strict'; const { - ArrayBufferIsView, - ArrayBufferPrototypeSlice, ArrayFrom, SafeSet, - TypedArrayPrototypeSlice, } = primordials; const { ChaCha20Poly1305CipherJob, KeyObjectHandle, kCryptoJobAsync, - kWebCryptoCipherDecrypt, - kWebCryptoCipherEncrypt, } = internalBinding('crypto'); const { @@ -46,35 +41,13 @@ function validateKeyLength(length) { throw lazyDOMException('Invalid key length', 'DataError'); } -async function c20pCipher(mode, key, data, algorithm) { - let tag; - switch (mode) { - case kWebCryptoCipherDecrypt: { - const slice = ArrayBufferIsView(data) ? - TypedArrayPrototypeSlice : ArrayBufferPrototypeSlice; - - if (data.byteLength < 16) { - throw lazyDOMException( - 'The provided data is too small.', - 'OperationError'); - } - - tag = slice(data, -16); - data = slice(data, 0, -16); - break; - } - case kWebCryptoCipherEncrypt: - tag = 16; - break; - } - - return await jobPromise(() => new ChaCha20Poly1305CipherJob( +function c20pCipher(mode, key, data, algorithm) { + return jobPromise(() => new ChaCha20Poly1305CipherJob( kCryptoJobAsync, mode, key[kKeyObject][kHandle], data, algorithm.iv, - tag, algorithm.additionalData)); } diff --git a/src/crypto/crypto_aes.cc b/src/crypto/crypto_aes.cc index b5495e59737eb6..fa619696ffd5b2 100644 --- a/src/crypto/crypto_aes.cc +++ b/src/crypto/crypto_aes.cc @@ -76,24 +76,28 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, } size_t tag_len = 0; + size_t data_len = in.size(); if (params.cipher.isGcmMode() || params.cipher.isOcbMode()) { + tag_len = params.length; switch (cipher_mode) { case kWebCryptoCipherDecrypt: { - // If in decrypt mode, the auth tag must be set in the params.tag. - CHECK(params.tag); + // In decrypt mode, the auth tag is appended to the end of the + // ciphertext. Split it off and set it on the cipher context. + if (data_len < tag_len) { + return WebCryptoCipherStatus::FAILED; + } + data_len -= tag_len; - // For OCB mode, we need to set the auth tag length before setting the - // tag if (params.cipher.isOcbMode()) { - if (!ctx.setAeadTagLength(params.tag.size())) { + if (!ctx.setAeadTagLength(tag_len)) { return WebCryptoCipherStatus::FAILED; } } ncrypto::Buffer<const char> buffer = { - .data = params.tag.data<char>(), - .len = params.tag.size(), + .data = in.data<char>() + data_len, + .len = tag_len, }; if (!ctx.setAeadTag(buffer)) { return WebCryptoCipherStatus::FAILED; @@ -101,14 +105,6 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, break; } case kWebCryptoCipherEncrypt: { - // In encrypt mode, we grab the tag length here. We'll use it to - // ensure that that allocated buffer has enough room for both the - // final block and the auth tag. Unlike our other AES-GCM implementation - // in CipherBase, in WebCrypto, the auth tag is concatenated to the end - // of the generated ciphertext and returned in the same ArrayBuffer. - tag_len = params.length; - - // For OCB mode, we need to set the auth tag length if (params.cipher.isOcbMode()) { if (!ctx.setAeadTagLength(tag_len)) { return WebCryptoCipherStatus::FAILED; @@ -122,7 +118,7 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, } size_t total = 0; - int buf_len = in.size() + ctx.getBlockSize() + tag_len; + int buf_len = data_len + ctx.getBlockSize() + (encrypt ? tag_len : 0); int out_len; ncrypto::Buffer<const unsigned char> buffer = { @@ -148,9 +144,9 @@ WebCryptoCipherStatus AES_Cipher(Environment* env, // Refs: https://github.com/nodejs/node/pull/38913#issuecomment-866505244 buffer = { .data = in.data<unsigned char>(), - .len = in.size(), + .len = data_len, }; - if (in.empty()) { + if (data_len == 0) { out_len = 0; } else if (!ctx.update(buffer, ptr, &out_len)) { return WebCryptoCipherStatus::FAILED; @@ -381,42 +377,17 @@ bool ValidateCounter( return true; } -bool ValidateAuthTag( - Environment* env, - CryptoJobMode mode, - WebCryptoCipherMode cipher_mode, - Local<Value> value, - AESCipherConfig* params) { - switch (cipher_mode) { - case kWebCryptoCipherDecrypt: { - if (!IsAnyBufferSource(value)) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env); - return false; - } - ArrayBufferOrViewContents<char> tag_contents(value); - if (!tag_contents.CheckSizeInt32()) [[unlikely]] { - THROW_ERR_OUT_OF_RANGE(env, "tagLength is too big"); - return false; - } - params->tag = mode == kCryptoJobAsync - ? tag_contents.ToCopy() - : tag_contents.ToByteSource(); - break; - } - case kWebCryptoCipherEncrypt: { - if (!value->IsUint32()) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env); - return false; - } - params->length = value.As<Uint32>()->Value(); - if (params->length > 128) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env); - return false; - } - break; - } - default: - UNREACHABLE(); +bool ValidateAuthTag(Environment* env, + Local<Value> value, + AESCipherConfig* params) { + if (!value->IsUint32()) { + THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env); + return false; + } + params->length = value.As<Uint32>()->Value(); + if (params->length > 128) { + THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env); + return false; } return true; } @@ -451,8 +422,7 @@ AESCipherConfig::AESCipherConfig(AESCipherConfig&& other) noexcept cipher(other.cipher), length(other.length), iv(std::move(other.iv)), - additional_data(std::move(other.additional_data)), - tag(std::move(other.tag)) {} + additional_data(std::move(other.additional_data)) {} AESCipherConfig& AESCipherConfig::operator=(AESCipherConfig&& other) noexcept { if (&other == this) return *this; @@ -466,7 +436,6 @@ void AESCipherConfig::MemoryInfo(MemoryTracker* tracker) const { if (mode == kCryptoJobAsync) { tracker->TrackFieldWithSize("iv", iv.size()); tracker->TrackFieldWithSize("additional_data", additional_data.size()); - tracker->TrackFieldWithSize("tag", tag.size()); } } @@ -510,7 +479,7 @@ Maybe<void> AESCipherTraits::AdditionalConfig( return Nothing<void>(); } } else if (params->cipher.isGcmMode() || params->cipher.isOcbMode()) { - if (!ValidateAuthTag(env, mode, cipher_mode, args[offset + 2], params) || + if (!ValidateAuthTag(env, args[offset + 2], params) || !ValidateAdditionalData(env, mode, args[offset + 3], params)) { return Nothing<void>(); } diff --git a/src/crypto/crypto_aes.h b/src/crypto/crypto_aes.h index 401ef70a5eba9f..5627f9020bad54 100644 --- a/src/crypto/crypto_aes.h +++ b/src/crypto/crypto_aes.h @@ -52,7 +52,6 @@ struct AESCipherConfig final : public MemoryRetainer { size_t length; ByteSource iv; // Used for both iv or counter ByteSource additional_data; - ByteSource tag; // Used only for authenticated modes (GCM) AESCipherConfig() = default; diff --git a/src/crypto/crypto_chacha20_poly1305.cc b/src/crypto/crypto_chacha20_poly1305.cc index bfe904c49ad771..0fd3e0517317ca 100644 --- a/src/crypto/crypto_chacha20_poly1305.cc +++ b/src/crypto/crypto_chacha20_poly1305.cc @@ -54,63 +54,6 @@ bool ValidateIV(Environment* env, return true; } -bool ValidateAuthTag(Environment* env, - CryptoJobMode mode, - WebCryptoCipherMode cipher_mode, - Local<Value> value, - ChaCha20Poly1305CipherConfig* params) { - switch (cipher_mode) { - case kWebCryptoCipherDecrypt: { - if (!IsAnyBufferSource(value)) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH( - env, "Authentication tag must be a buffer"); - return false; - } - - ArrayBufferOrViewContents<unsigned char> tag(value); - if (!tag.CheckSizeInt32()) [[unlikely]] { - THROW_ERR_OUT_OF_RANGE(env, "tag is too large"); - return false; - } - - if (tag.size() != kChaCha20Poly1305TagSize) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH( - env, "Invalid authentication tag length"); - return false; - } - - if (mode == kCryptoJobAsync) { - params->tag = tag.ToCopy(); - } else { - params->tag = tag.ToByteSource(); - } - break; - } - case kWebCryptoCipherEncrypt: { - // For encryption, the value should be the tag length (passed from - // JavaScript) We expect it to be the tag size constant for - // ChaCha20-Poly1305 - if (!value->IsUint32()) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH(env, "Tag length must be a number"); - return false; - } - - uint32_t tag_length = value.As<v8::Uint32>()->Value(); - if (tag_length != kChaCha20Poly1305TagSize) { - THROW_ERR_CRYPTO_INVALID_TAG_LENGTH( - env, "Invalid tag length for ChaCha20-Poly1305"); - return false; - } - // Tag is generated during encryption, not provided - break; - } - default: - UNREACHABLE(); - } - - return true; -} - bool ValidateAdditionalData(Environment* env, CryptoJobMode mode, Local<Value> value, @@ -138,8 +81,7 @@ ChaCha20Poly1305CipherConfig::ChaCha20Poly1305CipherConfig( : mode(other.mode), cipher(other.cipher), iv(std::move(other.iv)), - additional_data(std::move(other.additional_data)), - tag(std::move(other.tag)) {} + additional_data(std::move(other.additional_data)) {} ChaCha20Poly1305CipherConfig& ChaCha20Poly1305CipherConfig::operator=( ChaCha20Poly1305CipherConfig&& other) noexcept { @@ -154,7 +96,6 @@ void ChaCha20Poly1305CipherConfig::MemoryInfo(MemoryTracker* tracker) const { if (mode == kCryptoJobAsync) { tracker->TrackFieldWithSize("iv", iv.size()); tracker->TrackFieldWithSize("additional_data", additional_data.size()); - tracker->TrackFieldWithSize("tag", tag.size()); } } @@ -179,17 +120,9 @@ Maybe<void> ChaCha20Poly1305CipherTraits::AdditionalConfig( return Nothing<void>(); } - // Authentication tag parameter (only for decryption) or tag length (for - // encryption) - if (static_cast<unsigned int>(args.Length()) > offset + 1) { - if (!ValidateAuthTag(env, mode, cipher_mode, args[offset + 1], params)) { - return Nothing<void>(); - } - } - // Additional authenticated data parameter (optional) - if (static_cast<unsigned int>(args.Length()) > offset + 2) { - if (!ValidateAdditionalData(env, mode, args[offset + 2], params)) { + if (static_cast<unsigned int>(args.Length()) > offset + 1) { + if (!ValidateAdditionalData(env, mode, args[offset + 1], params)) { return Nothing<void>(); } } @@ -229,23 +162,25 @@ WebCryptoCipherStatus ChaCha20Poly1305CipherTraits::DoCipher( return WebCryptoCipherStatus::FAILED; } - size_t tag_len = 0; + size_t tag_len = kChaCha20Poly1305TagSize; + size_t data_len = in.size(); switch (cipher_mode) { case kWebCryptoCipherDecrypt: { - if (params.tag.size() != kChaCha20Poly1305TagSize) { + if (data_len < tag_len) { ... [truncated]
← Back to Alerts View on GitHub →