Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions ts/packages/core/src/domain/device-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
42 changes: 41 additions & 1 deletion ts/packages/core/test/device-id.test.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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));
});
});