Repository: denoland/deno
Pull request: fix(ext/node): `crypto.hkdfSync` returns wrong result for non-Uint8Array TypedArray inputs

This PR was created by [GitStart](https://gitstart.com/) to address the requirements from this ticket: [DENB-29913](https://clients.gitstart.com/deno-land-inc/16537/tickets/DENB-29913).

 --- 

**Issue**: The original HKDF implementation incorrectly handled TypedArrays by converting them through the toBuf() function, which only handles strings and Buffers. This caused TypedArrays to be processed incorrectly, losing their actual byte representation.\
\
**The Solution: toRawBytes() Function**\
\
**Perfect Node.js Compatibility**: Uses buffer, byteOffset, and byteLength to extract raw bytes

- **Handles All Cases**: ArrayBufferView, ArrayBuffer, and fallback to existing behavior


- **Preserves Semantics**: Strings still work exactly as before

Changed files:
filename: ext/node/polyfills/internal/crypto/hkdf.ts
patch: @@ -39,15 +39,29 @@ import {
 } from "ext:deno_node/internal/util/types.ts";
 import { isKeyObject } from "ext:deno_node/internal/crypto/_keys.ts";
 import { getHashes } from "ext:deno_node/internal/crypto/hash.ts";
+import { Buffer } from "node:buffer";
+
+// Consume raw bytes for any ArrayBufferView/ArrayBuffer; strings via toBuf.
+function toRawBytes(x: unknown): Buffer {
+  if (isArrayBufferView(x)) {
+    const v = x as ArrayBufferView;
+    return Buffer.from(v.buffer, v.byteOffset, v.byteLength);
+  }
+  if (isAnyArrayBuffer(x)) {
+    return Buffer.from(x as ArrayBufferLike);
+  }
+  // For strings / other BinaryLike, keep existing semantics (UTF-8 etc.)
+  return Buffer.from(toBuf(x as unknown as string));
+}
 
 const validateParameters = hideStackFrames((hash, key, salt, info, length) => {
   validateString(hash, "digest");
   key = prepareKey(key);
   validateByteSource(salt, "salt");
   validateByteSource(info, "info");
 
-  salt = new Uint8Array(toBuf(salt));
-  info = new Uint8Array(toBuf(info));
+  salt = toRawBytes(toBuf(salt));
+  info = toRawBytes(toBuf(info));
 
   validateInteger(length, "length", 0, kMaxLength);
 

filename: tests/unit_node/crypto/crypto_hkdf_test.ts
patch: @@ -1,6 +1,6 @@
 // Copyright 2018-2025 the Deno authors. MIT license.
-import { hkdfSync } from "node:crypto";
-import { assertEquals } from "@std/assert";
+import { hkdf, hkdfSync } from "node:crypto";
+import { assert, assertEquals } from "@std/assert";
 import { Buffer } from "node:buffer";
 import nodeFixtures from "../testdata/crypto_digest_fixtures.json" with {
   type: "json",
@@ -34,3 +34,460 @@ Deno.test("crypto.hkdfSync - compare with node", async (t) => {
     });
   }
 });
+
+Deno.test("crypto.hkdfSync - TypedArray byte representation fix", () => {
+  const secret = "secret";
+  const keyLen = 10;
+
+  // 1. CORE BUG FIX: Different TypedArrays use different byte representations
+  const stringResult = hkdfSync("sha256", secret, "salt", "info", keyLen);
+  const uint16Result = hkdfSync(
+    "sha256",
+    secret,
+    new Uint16Array(Buffer.from("salt")),
+    new Uint16Array(Buffer.from("info")),
+    keyLen,
+  );
+
+  const stringHex = Buffer.from(stringResult).toString("hex");
+  const uint16Hex = Buffer.from(uint16Result).toString("hex");
+
+  // Critical assertion: Uint16Array should produce different result than string
+  assert(
+    stringHex !== uint16Hex,
+    `Uint16Array should use different byte representation than string. ` +
+      `String: ${stringHex}, Uint16Array: ${uint16Hex}`,
+  );
+
+  // 2. BYTE-COMPATIBLE ARRAYS (should match string - 1 byte per element)
+  const byteCompatibleTypes = [
+    { name: "Uint8Array", ctor: Uint8Array, expected: "f6d2fcc47cb939deafe3" },
+    { name: "Int8Array", ctor: Int8Array, expected: "f6d2fcc47cb939deafe3" },
+  ];
+
+  // 3. MULTI-BYTE ARRAYS (should differ from string - multiple bytes per element)
+  const multiByteTypes = [
+    {
+      name: "Uint16Array",
+      ctor: Uint16Array,
+      expected: "db570fbe9a3a81e18bef",
+    },
+    { name: "Int16Array", ctor: Int16Array, expected: "db570fbe9a3a81e18bef" },
+    {
+      name: "Uint32Array",
+      ctor: Uint32Array,
+      expected: "5666e949b1b3c069b7fa",
+    },
+    { name: "Int32Array", ctor: Int32Array, expected: "5666e949b1b3c069b7fa" },
+  ];
+
+  // Test all TypedArray types systematically
+  for (
+    const { name, ctor, expected } of [
+      ...byteCompatibleTypes,
+      ...multiByteTypes,
+    ]
+  ) {
+    const result = hkdfSync(
+      "sha256",
+      secret,
+      new ctor(Buffer.from("salt")),
+      new ctor(Buffer.from("info")),
+      keyLen,
+    );
+    const resultHex = Buffer.from(result).toString("hex");
+
+    // Verify correct length
+    assertEquals(
+      result.byteLength,
+      keyLen,
+      `${name} should produce correct length result`,
+    );
+
+    // Verify Node.js-compatible result
+    assertEquals(
+      resultHex,
+      expected,
+      `${name} should produce Node.js-compatible result`,
+    );
+
+    // Verify TypedArray matches its underlying ArrayBuffer
+    const ta = new ctor(Buffer.from("salt"));
+    const saltSlice = new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
+    const taInfo = new ctor(Buffer.from("info"));
+    const infoSlice = new Uint8Array(
+      taInfo.buffer,
+      taInfo.byteOffset,
+      taInfo.byteLength,
+    );
+
+    const bufferResult = hkdfSync(
+      "sha256",
+      secret,
+      saltSlice,
+      infoSlice,
+      keyLen,
+    );
+    const bufferHex = Buffer.from(bufferResult).toString("hex");
+
+    assertEquals(
+      resultHex,
+      bufferHex,
+      `${name} should match its underlying ArrayBuffer representation`,
+    );
+  }
+
+  // 4. VERIFY BYTE-COMPATIBLE ARRAYS MATCH STRING
+  for (const { name, ctor } of byteCompatibleTypes) {
+    const result = hkdfSync(
+      "sha256",
+      secret,
+      new ctor(Buffer.from("salt")),
+      new ctor(Buffer.from("info")),
+      keyLen,
+    );
+    const resultHex = Buffer.from(result).toString("hex");
+
+    assertEquals(
+      resultHex,
+      stringHex,
+      `${name} should match string result (same byte representation)`,
+    );
+  }
+});
+
+Deno.test("crypto.hkdfSync - DataView inputs", () => {
+  const secret = "secret";
+  const saltBuffer = Buffer.from("salt");
+  const infoBuffer = Buffer.from("info");
+  const keyLen = 10;
+
+  // Test DataView inputs (should behave like Uint8Array)
+  const dataViewResult = hkdfSync(
+    "sha256",
+    secret,
+    new DataView(
+      saltBuffer.buffer,
+      saltBuffer.byteOffset,
+      saltBuffer.byteLength,
+    ),
+    new DataView(
+      infoBuffer.buffer,
+      infoBuffer.byteOffset,
+      infoBuffer.byteLength,
+    ),
+    keyLen,
+  );
+
+  assertEquals(
+    dataViewResult.byteLength,
+    keyLen,
+    "DataView result should have correct length",
+  );
+
+  // DataView should match string inputs since both represent the same bytes
+  const stringResult = hkdfSync("sha256", secret, "salt", "info", keyLen);
+  const dataViewHex = Buffer.from(dataViewResult).toString("hex");
+  const stringHex = Buffer.from(stringResult).toString("hex");
+
+  assertEquals(dataViewHex, stringHex, "DataView should match string result");
+  assertEquals(
+    dataViewHex,
+    "f6d2fcc47cb939deafe3",
+    "DataView should produce expected Node.js result",
+  );
+
+  // Test DataView with different underlying buffer scenarios
+  const largerBuffer = new ArrayBuffer(16);
+  const view = new Uint8Array(largerBuffer);
+  view.set([115, 97, 108, 116], 4); // "salt" at offset 4
+
+  const offsetDataView = new DataView(largerBuffer, 4, 4);
+  const offsetResult = hkdfSync(
+    "sha256",
+    secret,
+    offsetDataView,
+    "info",
+    keyLen,
+  );
+
+  assertEquals(
+    Buffer.from(offsetResult).toString("hex"),
+    stringHex,
+    "DataView with offset should still produce correct result",
+  );
+});
+
+Deno.test("crypto.hkdfSync - edge cases and error conditions", () => {
+  const secret = "secret";
+  const keyLen = 10;
+
+  // Test empty TypedArrays
+  const emptyResult = hkdfSync(
+    "sha256",
+    secret,
+    new Uint8Array(0), // Empty salt
+    new Uint8Array(0), // Empty info
+    keyLen,
+  );
+  assertEquals(emptyResult.byteLength, keyLen, "Empty TypedArrays should work");
+
+  // Test single-byte TypedArrays
+  const singleByteResult = hkdfSync(
+    "sha256",
+    secret,
+    new Uint8Array([65]), // Single byte 'A'
+    new Uint8Array([66]), // Single byte 'B'
+    keyLen,
+  );
+  assertEquals(
+    singleByteResult.byteLength,
+    keyLen,
+    "Single-byte TypedArrays should work",
+  );
+
+  // Test maximum allowed info length (1024 bytes)
+  const maxInfo = new Uint8Array(1024).fill(42);
+  const maxInfoResult = hkdfSync("sha256", secret, "salt", maxInfo, keyLen);
+  assertEquals(
+    maxInfoResult.byteLength,
+    keyLen,
+    "Maximum info length should work",
+  );
+
+  // Test info length exceeding limit should throw
+  const oversizedInfo = new Uint8Array(1025).fill(42);
+  let threwError = false;
+  try {
+    hkdfSync("sha256", secret, "salt", oversizedInfo, keyLen);
+  } catch (error) {
+    threwError = true;
+    assertEquals(
+      (error as Error).message.includes(
+        "must not contain more than 1024 bytes",
+      ),
+      true,
+      "Should throw specific error for oversized info",
+    );
+  }
+  assertEquals(threwError, true, "Oversized info should throw error");
+
+  // Test various key lengths
+  const keyLengths = [1, 32, 64, 255];
+  for (const len of keyLengths) {
+    const result = hkdfSync("sha256", secret, "salt", "info", len);
+    assertEquals(result.byteLength, len, `Key length ${len} should work`);
+  }
+
+  // Test different digest algorithms with TypedArrays
+  const digests = ["sha1", "sha256", "sha384", "sha512"];
+  for (const digest of digests) {
+    const result = hkdfSync(
+      digest,
+      secret,
+      new Uint16Array(Buffer.from("salt")),
+      new Uint16Array(Buffer.from("info")),
+      keyLen,
+    );
+    assertEquals(
+      result.byteLength,
+      keyLen,
+      `${digest} with TypedArrays should work`,
+    );
+  }
+});
+
+// === Async parity helpers ===
+function hkdfAsyncP(
+  digest: string,
+  secret: string,
+  salt: unknown,
+  info: unknown,
+  length: number,
+): Promise<ArrayBuffer | Uint8Array> {
+  return new Promise((resolve, reject) => {
+    // deno-lint-ignore no-explicit-any
+    hkdf(digest, secret, salt as any, info as any, length, (err, derived) => {
+      if (err) return reject(err);
+      resolve(derived!);
+    });
+  });
+}
+
+function asHex(ab: ArrayBuffer | Uint8Array): string {
+  const u8 = ab instanceof Uint8Array ? ab : new Uint8Array(ab);
+  return Buffer.from(u8).toString("hex");
+}
+
+Deno.test("crypto.hkdf (async) - strings match sync", async () => {
+  const secret = "secret", keyLen = 10;
+  const syncHex = asHex(hkdfSync("sha256", secret, "salt", "info", keyLen));
+  const asyncHex = asHex(
+    await hkdfAsyncP("sha256", secret, "salt", "info", keyLen),
+  );
+  assertEquals(asyncHex, syncHex);
+});
+
+Deno.test("crypto.hkdf (async) - TypedArray array-like inputs match sync", async () => {
+  const secret = "secret", keyLen = 10;
+  const cases = [
+    {
+      name: "Int8Array",
+      salt: new Int8Array(Buffer.from("salt")),
+      info: new Int8Array(Buffer.from("info")),
+    },
+    {
+      name: "Uint8Array",
+      salt: new Uint8Array(Buffer.from("salt")),
+      info: new Uint8Array(Buffer.from("info")),
+    },
+    {
+      name: "Int16Array",
+      salt: new Int16Array(Buffer.from("salt")),
+      info: new Int16Array(Buffer.from("info")),
+    },
+    {
+      name: "Uint16Array",
+      salt: new Uint16Array(Buffer.from("salt")),
+      info: new Uint16Array(Buffer.from("info")),
+    },
+    {
+      name: "Int32Array",
+      salt: new Int32Array(Buffer.from("salt")),
+      info: new Int32Array(Buffer.from("info")),
+    },
+    {
+      name: "Uint32Array",
+      salt: new Uint32Array(Buffer.from("salt")),
+      info: new Uint32Array(Buffer.from("info")),
+    },
+  ];
+  for (const { name, salt, info } of cases) {
+    const syncHex = asHex(hkdfSync("sha256", secret, salt, info, keyLen));
+    const asyncHex = asHex(
+      await hkdfAsyncP("sha256", secret, salt, info, keyLen),
+    );
+    assertEquals(
+      asyncHex,
+      syncHex,
+      `${name} async should equal sync for identical bytes`,
+    );
+  }
+});
+
+Deno.test("crypto.hkdf (async) - mixed TypedArray types match sync", async () => {
+  const secret = "secret", keyLen = 10;
+
+  // Uint16 salt + Uint8 info (array-like)
+  const saltU16 = new Uint16Array(Buffer.from("salt"));
+  const infoU8 = new Uint8Array(Buffer.from("info"));
+
+  const syncHex = asHex(hkdfSync("sha256", secret, saltU16, infoU8, keyLen));
+  const asyncHex = asHex(
+    await hkdfAsyncP("sha256", secret, saltU16, infoU8, keyLen),
+  );
+  assertEquals(
+    asyncHex,
+    syncHex,
+    "mixed (Uint16 + Uint8) async should equal sync",
+  );
+
+  // A couple more combos
+  const combos = [
+    {
+      salt: new Int8Array(Buffer.from("salt")),
+      info: new Uint16Array(Buffer.from("info")),
+      label: "Int8 + Uint16",
+    },
+    {
+      salt: new Uint32Array(Buffer.from("salt")),
+      info: new Uint8Array(Buffer.from("info")),
+      label: "Uint32 + Uint8",
+    },
+    {
+      salt: new Int16Array(Buffer.from("salt")),
+      info: new Int32Array(Buffer.from("info")),
+      label: "Int16 + Int32",
+    },
+  ];
+  for (const { salt, info, label } of combos) {
+    const sHex = asHex(hkdfSync("sha256", secret, salt, info, keyLen));
+    const aHex = asHex(await hkdfAsyncP("sha256", secret, salt, info, keyLen));
+    assertEquals(aHex, sHex, `${label} async should equal sync`);
+  }
+});
+
+Deno.test("crypto.hkdf (async) - DataView inputs match sync", async () => {
+  const secret = "secret", keyLen = 10;
+  const salt = Buffer.from("salt");
+  const info = Buffer.from("info");
+  const dvSalt = new DataView(salt.buffer, salt.byteOffset, salt.byteLength);
+  const dvInfo = new DataView(info.buffer, info.byteOffset, info.byteLength);
+
+  const syncHex = asHex(hkdfSync("sha256", secret, dvSalt, dvInfo, keyLen));
+  const asyncHex = asHex(
+    await hkdfAsyncP("sha256", secret, dvSalt, dvInfo, keyLen),
+  );
+  assertEquals(asyncHex, syncHex);
+});
+
+Deno.test("crypto.hkdf (async) - matches underlying ArrayBuffer bytes", async () => {
+  const secret = "secret", keyLen = 10;
+  const saltTA = new Uint16Array(Buffer.from("salt"));
+  const infoTA = new Uint16Array(Buffer.from("info"));
+
+  const asyncHexTA = asHex(
+    await hkdfAsyncP("sha256", secret, saltTA, infoTA, keyLen),
+  );
+
+  const saltBytes = new Uint8Array(
+    saltTA.buffer,
+    saltTA.byteOffset,
+    saltTA.byteLength,
+  );
+  const infoBytes = new Uint8Array(
+    infoTA.buffer,
+    infoTA.byteOffset,
+    infoTA.byteLength,
+  );
+  const asyncHexBuf = asHex(
+    await hkdfAsyncP("sha256", secret, saltBytes, infoBytes, keyLen),
+  );
+
+  assertEquals(
+    asyncHexTA,
+    asyncHexBuf,
+    "async TA result should equal async bytes result",
+  );
+});
+
+Deno.test("crypto.hkdf (async) - error cases (invalid digest, oversized info)", async () => {
+  const secret = "secret", keyLen = 10;
+
+  // Invalid digest -> reject
+  await (async () => {
+    let rejected = false;
+    try {
+      await hkdfAsyncP("sha256-bogus", secret, "salt", "info", keyLen);
+    } catch {
+      rejected = true;
+    }
+    assertEquals(rejected, true, "async hkdf should reject on invalid digest");
+  })();
+
+  // info > 1024 bytes -> reject
+  const oversizedInfo = new Uint8Array(1025).fill(1);
+  await (async () => {
+    let rejected = false;
+    try {
+      await hkdfAsyncP("sha256", secret, "salt", oversizedInfo, keyLen);
+    } catch {
+      rejected = true;
+    }
+    assertEquals(
+      rejected,
+      true,
+      "async hkdf should reject when info > 1024 bytes",
+    );
+  })();
+});