From 64e34584f56a163f840177e8cf9d7a70b817a749 Mon Sep 17 00:00:00 2001 From: fadesnay Date: Tue, 25 Aug 2026 23:20:53 +0100 Subject: [PATCH] fix(tlock): harden hex helpers and add seal/open input validation - Export isValidHex from commitment.ts, a boolean predicate matching fromHex's own accept/reject decisions (even length, optional 0x prefix, hex-only characters), and re-export it from index.ts. - sealBid now rejects a non-positive/non-integer Drand round with a clear error instead of letting it fail deep inside tlock-js. - openBid now rejects an empty ciphertext with a clear error instead of an obscure downstream decode failure. - Add unit tests for isValidHex and the new sealBid/openBid guard clauses. Fixes #191 --- packages/tlock/src/commitment.test.ts | 12 ++++++++++++ packages/tlock/src/commitment.ts | 7 +++++++ packages/tlock/src/index.ts | 1 + packages/tlock/src/seal.test.ts | 18 ++++++++++++++++++ packages/tlock/src/seal.ts | 8 ++++++++ 5 files changed, 46 insertions(+) diff --git a/packages/tlock/src/commitment.test.ts b/packages/tlock/src/commitment.test.ts index fc11cb3..73d3b4c 100644 --- a/packages/tlock/src/commitment.test.ts +++ b/packages/tlock/src/commitment.test.ts @@ -8,6 +8,7 @@ import { encodeBidPreimage, fromHex, i128ToBeBytes, + isValidHex, toHex, } from "./commitment.js"; @@ -72,3 +73,14 @@ test("fromHex rejects odd-length and non-hex input", () => { assert.throws(() => fromHex("12 3"), /invalid hex characters/); assert.throws(() => fromHex("0x12gg"), /invalid hex characters/); }); + +test("isValidHex agrees with fromHex's accept/reject decisions", () => { + for (const hex of ["abcdef", "ABCDEF", "0xAbCdEf", "0XABCDEF", "", "0x"]) { + assert.equal(isValidHex(hex), true, hex); + assert.doesNotThrow(() => fromHex(hex)); + } + for (const hex of ["abc", "zz", "12 3", "0x12gg"]) { + assert.equal(isValidHex(hex), false, hex); + assert.throws(() => fromHex(hex)); + } +}); diff --git a/packages/tlock/src/commitment.ts b/packages/tlock/src/commitment.ts index 9994867..bc1414c 100644 --- a/packages/tlock/src/commitment.ts +++ b/packages/tlock/src/commitment.ts @@ -77,6 +77,13 @@ 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. +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); +} + export function fromHex(hex: string): Uint8Array { const clean = /^0x/i.test(hex) ? hex.slice(2) : hex; if (clean.length % 2 !== 0) throw new Error("odd hex length"); diff --git a/packages/tlock/src/index.ts b/packages/tlock/src/index.ts index 0cdd65f..36d1a49 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -6,6 +6,7 @@ export { beBytesToI128, toHex, fromHex, + isValidHex, VALUE_BYTES, NONCE_BYTES, PREIMAGE_BYTES, diff --git a/packages/tlock/src/seal.test.ts b/packages/tlock/src/seal.test.ts index 66543ca..224c302 100644 --- a/packages/tlock/src/seal.test.ts +++ b/packages/tlock/src/seal.test.ts @@ -99,6 +99,24 @@ test( }, ); +test("sealBid rejects a non-positive Drand round", async () => { + const client = quicknet(); + for (const round of [0, -1, -100]) { + await assert.rejects( + sealBid({ value: 1n, nonce: generateNonce(), round, client }), + /round must be a positive integer/, + ); + } +}); + +test("openBid rejects an empty ciphertext", async () => { + const client = quicknet(); + await assert.rejects( + openBid(new Uint8Array(0), client), + /ciphertext must not be empty/, + ); +}); + test( "a bid sealed to a future round cannot be opened — the seal holds", { timeout: NET_TIMEOUT }, diff --git a/packages/tlock/src/seal.ts b/packages/tlock/src/seal.ts index 0cfdf83..b118434 100644 --- a/packages/tlock/src/seal.ts +++ b/packages/tlock/src/seal.ts @@ -42,6 +42,10 @@ 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}`); + } + const preimage = encodeBidPreimage(value, nonce); const h = commitment(value, nonce); const armored = await timelockEncrypt(round, TlockBuffer.from(preimage), client); @@ -68,6 +72,10 @@ export async function openBid( ciphertext: Uint8Array, client: DrandClient, ): Promise { + if (ciphertext.length === 0) { + throw new Error("ciphertext must not be empty"); + } + const armored = utf8Decode.decode(ciphertext); const plaintext = await timelockDecrypt(armored, client); return decodeBidPreimage(Uint8Array.from(plaintext));