diff --git a/packages/tlock/src/commitment.test.ts b/packages/tlock/src/commitment.test.ts index aacf8a8..f4199ff 100644 --- a/packages/tlock/src/commitment.test.ts +++ b/packages/tlock/src/commitment.test.ts @@ -66,13 +66,50 @@ test("fromHex decodes lowercase, uppercase, and prefixed values", () => { test("fromHex accepts empty input as an empty byte array", () => { assert.deepEqual([...fromHex("")], []); assert.deepEqual([...fromHex("0x")], []); + assert.deepEqual([...fromHex("0X")], []); }); -test("fromHex rejects odd-length and non-hex input", () => { +test("fromHex rejects odd-length, non-hex, and non-string input", () => { assert.throws(() => fromHex("abc"), /odd hex length/); + assert.throws(() => fromHex("0xabc"), /odd hex length/); + assert.throws(() => fromHex("0Xabc"), /odd hex length/); assert.throws(() => fromHex("zz"), /invalid hex characters/); assert.throws(() => fromHex("12 3"), /invalid hex characters/); + assert.throws(() => fromHex("12g4"), /invalid hex characters/); assert.throws(() => fromHex("0x12gg"), /invalid hex characters/); + assert.throws(() => fromHex(123 as any), /hex must be a string/); + assert.throws(() => fromHex(null as any), /hex must be a string/); +}); + +test("isValidHex accepts valid even-length hex strings", () => { + assert.equal(isValidHex("abcdef"), true); + assert.equal(isValidHex("ABCDEF"), true); + assert.equal(isValidHex("0xAbCdEf"), true); + assert.equal(isValidHex("0XABCDEF"), true); + assert.equal(isValidHex(""), true); + assert.equal(isValidHex("0x"), true); + assert.equal(isValidHex("0X"), true); + assert.equal(isValidHex("00"), true); + assert.equal(isValidHex("0x1234567890abcdefABCDEF"), true); +}); + +test("isValidHex rejects odd-length, non-hex, and non-string inputs", () => { + assert.equal(isValidHex("abc"), false); + assert.equal(isValidHex("0xabc"), false); + assert.equal(isValidHex("0Xabc"), false); + assert.equal(isValidHex("zz"), false); + assert.equal(isValidHex("12 3"), false); + assert.equal(isValidHex("12g4"), false); + assert.equal(isValidHex("0x12gg"), false); + assert.equal(isValidHex(123 as any), false); + assert.equal(isValidHex(null as any), false); + assert.equal(isValidHex(undefined as any), false); + assert.equal(isValidHex({} as any), false); +}); + +test("toHex produces clean lowercase hex strings", () => { + assert.equal(toHex(new Uint8Array([0x00, 0x0f, 0xab, 0xcd, 0xef])), "000fabcdef"); + assert.equal(toHex(new Uint8Array([])), ""); }); test("isValidHex agrees with fromHex's accept/reject decisions", () => { diff --git a/packages/tlock/src/commitment.ts b/packages/tlock/src/commitment.ts index bc1414c..c4d383f 100644 --- a/packages/tlock/src/commitment.ts +++ b/packages/tlock/src/commitment.ts @@ -77,17 +77,24 @@ export function toHex(bytes: Uint8Array): string { .join(""); } -/// True iff `hex` is a valid hex string (optionally 0x-prefixed) that fromHex -/// would accept: even length, and every remaining character is a hex digit. +const HEX_RE = /^[0-9a-fA-F]*$/; + +/** + * Returns true if `hex` (optionally `0x` or `0X` prefixed) is a valid, even-length hexadecimal string. + */ export function isValidHex(hex: string): boolean { - const clean = /^0x/i.test(hex) ? hex.slice(2) : hex; - return clean.length % 2 === 0 && /^[0-9a-fA-F]*$/.test(clean); + if (typeof hex !== "string") return false; + const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; + return clean.length % 2 === 0 && HEX_RE.test(clean); } export function fromHex(hex: string): Uint8Array { - const clean = /^0x/i.test(hex) ? hex.slice(2) : hex; + if (typeof hex !== "string") { + throw new Error("hex must be a string"); + } + const clean = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex; if (clean.length % 2 !== 0) throw new Error("odd hex length"); - if (!/^[0-9a-fA-F]*$/.test(clean)) { + if (!HEX_RE.test(clean)) { throw new Error("invalid hex characters"); } const out = new Uint8Array(clean.length / 2); diff --git a/packages/tlock/src/seal.test.ts b/packages/tlock/src/seal.test.ts index 1885e8e..75321fb 100644 --- a/packages/tlock/src/seal.test.ts +++ b/packages/tlock/src/seal.test.ts @@ -134,3 +134,54 @@ test( await assert.rejects(openBid(sealed.ciphertext, client)); }, ); + +test("sealBid rejects non-positive or non-integer round numbers", async () => { + const client = quicknet(); + const nonce = generateNonce(); + const value = 100n; + + await assert.rejects( + () => sealBid({ value, nonce, round: 0, client }), + (err: any) => err instanceof RangeError && /round must be a positive integer/.test(err.message), + ); + await assert.rejects( + () => sealBid({ value, nonce, round: -5, client }), + (err: any) => err instanceof RangeError && /round must be a positive integer/.test(err.message), + ); + await assert.rejects( + () => sealBid({ value, nonce, round: 1.5, client }), + (err: any) => err instanceof RangeError && /round must be a positive integer/.test(err.message), + ); + await assert.rejects( + () => sealBid({ value, nonce, round: NaN, client }), + (err: any) => err instanceof RangeError && /round must be a positive integer/.test(err.message), + ); +}); + +test("sealBid rejects invalid nonce lengths", async () => { + const client = quicknet(); + const value = 100n; + const round = 1000; + + await assert.rejects( + () => sealBid({ value, nonce: new Uint8Array(16), round, client }), + /nonce must be 32 bytes/, + ); + await assert.rejects( + () => sealBid({ value, nonce: new Uint8Array(31), round, client }), + /nonce must be 32 bytes/, + ); + await assert.rejects( + () => sealBid({ value, nonce: new Uint8Array(33), round, client }), + /nonce must be 32 bytes/, + ); +}); + +test("openBid rejects empty ciphertext", async () => { + const client = quicknet(); + await assert.rejects( + () => openBid(new Uint8Array(0), client), + /ciphertext is empty/, + ); +}); + diff --git a/packages/tlock/src/seal.ts b/packages/tlock/src/seal.ts index b118434..9aff356 100644 --- a/packages/tlock/src/seal.ts +++ b/packages/tlock/src/seal.ts @@ -42,8 +42,11 @@ export function generateNonce(): Uint8Array { export async function sealBid(params: SealBidParams): Promise { const { value, nonce, round, client, identity, auditorPublicKey } = params; - if (!Number.isInteger(round) || round <= 0) { - throw new Error(`round must be a positive integer, got ${round}`); + if (!Number.isInteger(round) || round < 1) { + throw new RangeError(`round must be a positive integer, got ${round}`); + } + if (!nonce || nonce.length !== NONCE_BYTES) { + throw new Error(`nonce must be ${NONCE_BYTES} bytes, got ${nonce?.length}`); } const preimage = encodeBidPreimage(value, nonce); @@ -72,10 +75,9 @@ export async function openBid( ciphertext: Uint8Array, client: DrandClient, ): Promise { - if (ciphertext.length === 0) { - throw new Error("ciphertext must not be empty"); + if (!ciphertext || ciphertext.length === 0) { + throw new Error("ciphertext is empty; ciphertext must not be empty"); } - const armored = utf8Decode.decode(ciphertext); const plaintext = await timelockDecrypt(armored, client); return decodeBidPreimage(Uint8Array.from(plaintext));