From 682e616b11dc8c7cba1c20cada8832d4b19a4d99 Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 25 Jul 2026 14:08:26 +0900 Subject: [PATCH 1/2] feat(hex): add byte decoding --- README.md | 10 +++++++++- src/hex.test.ts | 27 +++++++++++++++++++++++++++ src/hex.ts | 19 ++++++------------- 3 files changed, 42 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cf541d6..fcda007 100644 --- a/README.md +++ b/README.md @@ -353,12 +353,20 @@ const encodedData = hex.encode("Data to encode"); ### Decoding -Decode hexadecimal-encoded data. Input can be a string or `ArrayBuffer`. +Decode hexadecimal-encoded data into a string. Input can be a string, `ArrayBuffer`, or `TypedArray`. Uppercase and lowercase hexadecimal are both accepted. ```ts const decodedData = hex.decode(encodedData); ``` +### Converting to bytes + +Convert a hexadecimal string into its raw bytes. Use this when the decoded value is binary data, such as a signature or key, rather than UTF-8 text. + +```ts +const bytes = hex.toBytes("48656c6c6f"); // Uint8Array([72, 101, 108, 108, 111]) +``` + ## Binary A utilities provide a simple interface to encode and decode data in binary format. It uses `TextEncode` and `TextDecoder` to encode and decode data respectively. diff --git a/src/hex.test.ts b/src/hex.test.ts index 7bbaa19..30c8dd6 100644 --- a/src/hex.test.ts +++ b/src/hex.test.ts @@ -19,6 +19,29 @@ describe("hex", () => { }); }); + describe("toBytes", () => { + it("should convert hexadecimal to bytes", () => { + expect(hex.toBytes("48656c6c6f")).toEqual( + Uint8Array.from([72, 101, 108, 108, 111]), + ); + }); + + it("should accept uppercase hexadecimal", () => { + expect(hex.toBytes("DEADBEEF")).toEqual( + Uint8Array.from([222, 173, 190, 239]), + ); + }); + + it("should convert an empty string to empty bytes", () => { + expect(hex.toBytes("")).toEqual(new Uint8Array()); + }); + + it("should throw an error for invalid hexadecimal", () => { + expect(() => hex.toBytes("123")).toThrow(Error); + expect(() => hex.toBytes("zzzz")).toThrow(Error); + }); + }); + describe("decode", () => { it("should decode a hexadecimal string to its original value", () => { const expected = "Hello, World!"; @@ -30,6 +53,10 @@ describe("hex", () => { expect(hex.decode(Buffer.from(expected).toString("hex"))).toBe(expected); }); + it("should decode uppercase hexadecimal", () => { + expect(hex.decode("48656C6C6F")).toBe("Hello"); + }); + it("should throw an error for an odd-length string", () => { const input = "123"; expect(() => hex.decode(input)).toThrow(Error); diff --git a/src/hex.ts b/src/hex.ts index 2021509..f621d2b 100644 --- a/src/hex.ts +++ b/src/hex.ts @@ -1,7 +1,9 @@ -import type { TypedArray } from "./type"; +import { hexToBytes } from "@noble/hashes/utils.js"; +import type { TypedArray, Uint8Array_ } from "./type"; import { toUint8Array } from "./bytes"; -const hexadecimal = "0123456789abcdef"; +const toBytes = (data: string): Uint8Array_ => hexToBytes(data) as Uint8Array_; + export const hex = { encode: (data: string | ArrayBuffer | TypedArray) => { const buffer = toUint8Array(data); @@ -19,18 +21,9 @@ export const hex = { return ""; } if (typeof data === "string") { - if (data.length % 2 !== 0) { - throw new Error("Invalid hexadecimal string"); - } - if (!new RegExp(`^[${hexadecimal}]+$`).test(data)) { - throw new Error("Invalid hexadecimal string"); - } - const result = new Uint8Array(data.length / 2); - for (let i = 0; i < data.length; i += 2) { - result[i / 2] = parseInt(data.slice(i, i + 2), 16); - } - return new TextDecoder().decode(result); + return new TextDecoder().decode(toBytes(data)); } return new TextDecoder().decode(data); }, + toBytes, }; From 5ccd2c7eecf2faeab0ed36985ac8835eecc13080 Mon Sep 17 00:00:00 2001 From: Taesu Date: Sat, 25 Jul 2026 14:08:42 +0900 Subject: [PATCH 2/2] fix(hmac): handle signature encodings correctly --- src/hmac.test.ts | 59 +++++++++++++++++++++++++++++++++++++----------- src/hmac.ts | 14 +++++++----- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/src/hmac.test.ts b/src/hmac.test.ts index 6109f20..beb23aa 100644 --- a/src/hmac.test.ts +++ b/src/hmac.test.ts @@ -5,7 +5,6 @@ describe("hmac module", () => { const algorithm = "SHA-256"; const testKey = "super-secret-key"; const testData = "Hello, HMAC!"; - let signature: ArrayBuffer; it("imports a key for HMAC", async () => { const cryptoKey = await createHMAC().importKey(testKey, "sign"); @@ -15,32 +14,66 @@ describe("hmac module", () => { }); it("signs data using HMAC", async () => { - signature = await createHMAC().sign(testKey, testData); + const signature = await createHMAC().sign(testKey, testData); + expect(signature).toBeInstanceOf(ArrayBuffer); expect(signature.byteLength).toBeGreaterThan(0); }); it("verifies HMAC signature", async () => { - const isValid = await createHMAC().verify(testKey, testData, signature); + const hmac = createHMAC(algorithm); + const signature = await hmac.sign(testKey, testData); + const isValid = await hmac.verify(testKey, testData, signature); + expect(isValid).toBe(true); }); it("fails verification for modified data", async () => { - const isValid = await createHMAC(algorithm).verify( - testKey, - "Modified data", - signature, - ); + const hmac = createHMAC(algorithm); + const signature = await hmac.sign(testKey, testData); + const isValid = await hmac.verify(testKey, "Modified data", signature); + expect(isValid).toBe(false); }); it("fails verification for a different key", async () => { + const hmac = createHMAC(algorithm); + const signature = await hmac.sign(testKey, testData); const differentKey = "different-secret-key"; - const isValid = await createHMAC(algorithm).verify( - differentKey, - testData, - signature, - ); + const isValid = await hmac.verify(differentKey, testData, signature); + expect(isValid).toBe(false); }); + + it.each([ + ["hex", "be961e914067801857b0429afaa685c9b31c35b8175bd2582d95a2887c5162ec"], + ["base64", "vpYekUBngBhXsEKa+qaFybMcNbgXW9JYLZWiiHxRYuw="], + ["base64url", "vpYekUBngBhXsEKa-qaFybMcNbgXW9JYLZWiiHxRYuw="], + ["base64urlnopad", "vpYekUBngBhXsEKa-qaFybMcNbgXW9JYLZWiiHxRYuw"], + ] as const)( + "signs and verifies valid and tampered data using %s encoding", + async (encoding, expected) => { + const hmac = createHMAC(algorithm, encoding); + const encodedSignature = await hmac.sign(testKey, testData); + const replacement = encodedSignature.startsWith("0") ? "1" : "0"; + const tamperedSignature = `${replacement}${encodedSignature.slice(1)}`; + + expect(encodedSignature).toBe(expected); + await expect( + hmac.verify(testKey, testData, encodedSignature), + ).resolves.toBe(true); + await expect( + hmac.verify(testKey, testData, tamperedSignature), + ).resolves.toBe(false); + }, + ); + + it("verifies uppercase hexadecimal signatures", async () => { + const hmac = createHMAC(algorithm, "hex"); + const encodedSignature = await hmac.sign(testKey, testData); + + await expect( + hmac.verify(testKey, testData, encodedSignature.toUpperCase()), + ).resolves.toBe(true); + }); }); diff --git a/src/hmac.ts b/src/hmac.ts index eaa8664..f046c67 100644 --- a/src/hmac.ts +++ b/src/hmac.ts @@ -36,11 +36,12 @@ export const createHMAC = ( if (encoding === "hex") { return hex.encode(signature) as E extends "none" ? ArrayBuffer : string; } - if ( - encoding === "base64" || - encoding === "base64url" || - encoding === "base64urlnopad" - ) { + if (encoding === "base64") { + return base64.encode(signature) as E extends "none" + ? ArrayBuffer + : string; + } + if (encoding === "base64url" || encoding === "base64urlnopad") { return base64Url.encode(signature, { padding: encoding !== "base64urlnopad", }) as E extends "none" ? ArrayBuffer : string; @@ -56,7 +57,8 @@ export const createHMAC = ( hmacKey = await hmac.importKey(hmacKey, "verify"); } if (encoding === "hex") { - signature = hex.decode(signature); + signature = + typeof signature === "string" ? hex.toBytes(signature) : signature; } if ( encoding === "base64" ||