Repository: denoland/deno
Pull request: fix(ext/node): improve GCM cipher compat with Node.js

## Summary

GCM-only fixes extracted from #32745 for easier review:

- **Empty IV validation**: `createCipheriv`/`createDecipheriv` with empty IV now throws "Invalid initialization vector" (was silently accepted)
- **Auth tag length validation**: `setAuthTag` now validates GCM tag lengths (must be 4, 8, 12-16) even when `authTagLength` option wasn't set at construction
- **State checks**: `setAuthTag` can't be called twice; `setAAD` can't be called after `final()`
- **Error message**: Auth failure now says "Unsupported state or unable to authenticate data" matching Node.js

## What's NOT in this PR

The remaining #32745 changes will follow in separate PRs:
1. Sign/verify fixes (DER/pkcs8 keys, RSA-PSS salt lengths, DSA PEM)
2. ChaCha20-Poly1305 implementation
3. AES-CCM implementation

The `test-crypto-authenticated.js` compat test requires CCM support to pass fully, so it will be enabled when CCM lands.

## Test plan
- Added unit tests for all fixes: empty IV rejection, auth tag length validation (invalid + valid lengths), setAuthTag double-call, setAAD after final, getAuthTag before final
- Updated existing GCM auth failure error message assertion
- All existing crypto unit tests pass

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Changed files:
filename: ext/node/polyfills/internal/crypto/cipher.ts
patch: @@ -295,6 +295,9 @@ Cipheriv.prototype.setAAD = function (
     plaintextLength: number;
   },
 ) {
+  if (this._finalized) {
+    throw new ERR_CRYPTO_INVALID_STATE("setAAD");
+  }
   op_node_cipheriv_set_aad(this._context, buffer);
   return this;
 };
@@ -531,6 +534,9 @@ Decipheriv.prototype.setAAD = function (
     plaintextLength: number;
   },
 ) {
+  if (this._finalized) {
+    throw new ERR_CRYPTO_INVALID_STATE("setAAD");
+  }
   op_node_decipheriv_set_aad(this._context, buffer);
   return this;
 };
@@ -539,6 +545,9 @@ Decipheriv.prototype.setAuthTag = function (
   buffer: BinaryLike,
   _encoding?: string,
 ) {
+  if (this._authTag) {
+    throw new ERR_CRYPTO_INVALID_STATE("setAuthTag");
+  }
   op_node_decipheriv_auth_tag(this._context, buffer.byteLength);
   this._authTag = buffer;
   return this;

filename: ext/node_crypto/cipher.rs
patch: @@ -251,6 +251,9 @@ impl Cipher {
         if key.len() != aes::Aes128::key_size() {
           return Err(CipherError::InvalidKeyLength);
         }
+        if iv.is_empty() {
+          return Err(CipherError::InvalidInitializationVector);
+        }
 
         if let Some(tag_len) = auth_tag_length
           && !is_valid_gcm_tag_length(tag_len)
@@ -267,6 +270,9 @@ impl Cipher {
         if key.len() != aes::Aes256::key_size() {
           return Err(CipherError::InvalidKeyLength);
         }
+        if iv.is_empty() {
+          return Err(CipherError::InvalidInitializationVector);
+        }
 
         if let Some(tag_len) = auth_tag_length
           && !is_valid_gcm_tag_length(tag_len)
@@ -553,7 +559,7 @@ pub enum DecipherError {
   #[error("bad decrypt")]
   CannotUnpadInputData,
   #[class(type)]
-  #[error("Failed to authenticate data")]
+  #[error("Unsupported state or unable to authenticate data")]
   DataAuthenticationFailed,
   #[class(type)]
   #[error("Unknown cipher {0}")]
@@ -644,6 +650,9 @@ impl Decipher {
         if key.len() != aes::Aes128::key_size() {
           return Err(DecipherError::InvalidKeyLength);
         }
+        if iv.is_empty() {
+          return Err(DecipherError::InvalidInitializationVector);
+        }
 
         if let Some(tag_len) = auth_tag_length
           && !is_valid_gcm_tag_length(tag_len)
@@ -660,6 +669,9 @@ impl Decipher {
         if key.len() != aes::Aes256::key_size() {
           return Err(DecipherError::InvalidKeyLength);
         }
+        if iv.is_empty() {
+          return Err(DecipherError::InvalidInitializationVector);
+        }
 
         if let Some(tag_len) = auth_tag_length
           && !is_valid_gcm_tag_length(tag_len)
@@ -732,6 +744,11 @@ impl Decipher {
           return Err(DecipherError::InvalidAuthTag(length));
         }
       }
+      Decipher::Aes128Gcm(_, None) | Decipher::Aes256Gcm(_, None) => {
+        if !is_valid_gcm_tag_length(length) {
+          return Err(DecipherError::InvalidAuthTag(length));
+        }
+      }
       _ => {}
     }
     Ok(())

filename: tests/unit_node/crypto/crypto_cipher_gcm_test.ts
patch: @@ -141,7 +141,7 @@ Deno.test({
     assertThrows(
       () => decipher.final(),
       TypeError,
-      "Failed to authenticate data",
+      "Unsupported state or unable to authenticate data",
     );
   },
 });
@@ -207,3 +207,122 @@ Deno.test({
     );
   },
 });
+
+Deno.test({
+  name: "aes gcm rejects empty IV",
+  fn() {
+    for (const algo of ["aes-128-gcm", "aes-256-gcm"] as const) {
+      const keyLen = algo === "aes-128-gcm" ? 16 : 32;
+      assertThrows(
+        () =>
+          crypto.createCipheriv(algo, Buffer.alloc(keyLen), Buffer.alloc(0)),
+        TypeError,
+        "Invalid initialization vector",
+      );
+      assertThrows(
+        () =>
+          crypto.createDecipheriv(algo, Buffer.alloc(keyLen), Buffer.alloc(0)),
+        TypeError,
+        "Invalid initialization vector",
+      );
+    }
+  },
+});
+
+Deno.test({
+  name: "aes gcm setAuthTag validates tag length",
+  fn() {
+    const invalidLengths = [0, 1, 2, 3, 5, 6, 7, 9, 10, 11, 17];
+    for (const length of invalidLengths) {
+      const d = crypto.createDecipheriv(
+        "aes-128-gcm",
+        Buffer.alloc(16),
+        Buffer.alloc(12),
+      );
+      assertThrows(
+        () => d.setAuthTag(Buffer.alloc(length)),
+        TypeError,
+        "Invalid authentication tag length",
+      );
+      // Finalize to release the underlying resource.
+      try {
+        d.final();
+      } catch {
+        // final() throws because no valid auth tag was set — that's expected.
+      }
+    }
+
+    // Valid lengths should not throw — use a full encrypt/decrypt cycle
+    // to avoid leaking resources.
+    for (const length of [4, 8, 12, 13, 14, 15, 16]) {
+      const key = Buffer.alloc(16);
+      const iv = Buffer.alloc(12);
+      const cipher = crypto.createCipheriv("aes-128-gcm", key, iv, {
+        authTagLength: length,
+      });
+      cipher.final();
+      const tag = cipher.getAuthTag();
+
+      const d = crypto.createDecipheriv("aes-128-gcm", key, iv, {
+        authTagLength: length,
+      });
+      d.setAuthTag(tag);
+      d.final();
+    }
+  },
+});
+
+Deno.test({
+  name: "aes gcm setAuthTag cannot be called twice",
+  fn() {
+    const key = Buffer.alloc(16);
+    const iv = Buffer.alloc(12);
+    const cipher = crypto.createCipheriv("aes-128-gcm", key, iv);
+    cipher.final();
+    const tag = cipher.getAuthTag();
+
+    const d = crypto.createDecipheriv("aes-128-gcm", key, iv);
+    d.setAuthTag(tag);
+    assertThrows(
+      () => d.setAuthTag(tag),
+      Error,
+      "Invalid state",
+    );
+    d.final();
+  },
+});
+
+Deno.test({
+  name: "aes gcm setAAD cannot be called after final",
+  fn() {
+    const cipher = crypto.createCipheriv(
+      "aes-128-gcm",
+      Buffer.alloc(16),
+      Buffer.alloc(12),
+    );
+    cipher.final();
+    assertThrows(
+      () => cipher.setAAD(Buffer.from("aad")),
+      Error,
+      "Invalid state",
+    );
+  },
+});
+
+Deno.test({
+  name: "aes gcm getAuthTag before final throws state error",
+  fn() {
+    const cipher = crypto.createCipheriv(
+      "aes-128-gcm",
+      Buffer.alloc(16),
+      Buffer.alloc(12),
+    );
+    cipher.update("data", "utf8");
+    assertThrows(
+      () => cipher.getAuthTag(),
+      Error,
+      "Invalid state",
+    );
+    cipher.final();
+  },
+});