diff --git a/ts/packages/core/src/domain/device-id.ts b/ts/packages/core/src/domain/device-id.ts index cb24413..0699353 100644 --- a/ts/packages/core/src/domain/device-id.ts +++ b/ts/packages/core/src/domain/device-id.ts @@ -20,19 +20,29 @@ export function deviceIdToHex(device: DeviceId): string { return bytesToHex(device); } -/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */ -export function deviceIdFromHex(hex: string): DeviceId { - if (!/^[0-9a-f]{64}$/.test(hex)) { +/** The arbitrary-length reverse of bytesToHex -- decodes a lowercase, byte-exact hex string back into the bytes it encodes. Throws on an odd-length string, a non-hex character, or a non-lowercase one, rather than silently truncating or normalising a malformed input, matching deviceIdFromHex's own fail-closed convention for the fixed-length case. */ +export function bytesFromHex(hex: string): Uint8Array { + if (!/^([0-9a-f]{2})*$/.test(hex)) { throw new Error( - `expected a 64-character lowercase hex string, got ${JSON.stringify(hex)}`, + `expected an even-length, lowercase hex string, got ${JSON.stringify(hex)}`, ); } - const bytes = new Uint8Array(DEVICE_ID_HEX_LENGTH / HEX_BYTE_WIDTH); + const bytes = new Uint8Array(hex.length / HEX_BYTE_WIDTH); for (let i = 0; i < bytes.length; i++) { bytes[i] = Number.parseInt( hex.slice(i * HEX_BYTE_WIDTH, (i + 1) * HEX_BYTE_WIDTH), HEX_RADIX, ); } - return deviceIdSchema.parse(bytes); + return bytes; +} + +/** Parses a lowercase, 64-character device-id-hex string back into the 32-byte DeviceId it encodes. Throws on anything that isn't exactly that shape, rather than silently truncating or zero-padding a malformed input. */ +export function deviceIdFromHex(hex: string): DeviceId { + if (hex.length !== DEVICE_ID_HEX_LENGTH) { + throw new Error( + `expected a 64-character lowercase hex string, got ${JSON.stringify(hex)}`, + ); + } + return deviceIdSchema.parse(bytesFromHex(hex)); } diff --git a/ts/packages/core/test/device-id.test.ts b/ts/packages/core/test/device-id.test.ts index ca49a35..34d1413 100644 --- a/ts/packages/core/test/device-id.test.ts +++ b/ts/packages/core/test/device-id.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { deviceIdFromHex, deviceIdToHex } from "../src/domain/device-id.js"; +import { + bytesFromHex, + bytesToHex, + deviceIdFromHex, + deviceIdToHex, +} from "../src/domain/device-id.js"; import { deviceIdFromFillHex } from "./hex.js"; const SHA256_BYTE_LENGTH = 32; // device-id = SHA-256(identity-key.public-key) @@ -34,3 +39,38 @@ describe("deviceIdFromHex", () => { expect(() => deviceIdFromHex("AA".repeat(SHA256_BYTE_LENGTH))).toThrow(); // uppercase, not the canonical lowercase form }); }); + +const ARBITRARY_BYTE_SEQUENCE_HEX = "002aff100a"; + +describe("bytesFromHex", () => { + it("round-trips an arbitrary-length byte string through bytesToHex", () => { + const original = bytesFromHex(ARBITRARY_BYTE_SEQUENCE_HEX); + expect(bytesFromHex(bytesToHex(original))).toStrictEqual(original); + }); + + it("decodes an odd-looking but valid hex pair-by-pair", () => { + expect(bytesFromHex("002aff")).toStrictEqual( + Uint8Array.from( + Array.from({ length: 3 }, (_unused, index) => + Number.parseInt("002aff".slice(index * 2, index * 2 + 2), 16), + ), + ), + ); + }); + + it("rejects an odd-length string", () => { + expect(() => bytesFromHex("abc")).toThrow(); + }); + + it("rejects non-hex characters", () => { + expect(() => bytesFromHex("zz")).toThrow(); + }); + + it("rejects uppercase, not the canonical lowercase form", () => { + expect(() => bytesFromHex("AA")).toThrow(); + }); + + it("accepts an empty string as zero bytes", () => { + expect(bytesFromHex("")).toStrictEqual(new Uint8Array(0)); + }); +});