diff --git a/packages/tlock/src/commitment.test.ts b/packages/tlock/src/commitment.test.ts index fc11cb3..da42255 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"; @@ -64,11 +65,49 @@ 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([])), ""); +}); + diff --git a/packages/tlock/src/commitment.ts b/packages/tlock/src/commitment.ts index 9994867..cab1009 100644 --- a/packages/tlock/src/commitment.ts +++ b/packages/tlock/src/commitment.ts @@ -77,10 +77,24 @@ export function toHex(bytes: Uint8Array): string { .join(""); } +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 { + 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); @@ -89,3 +103,4 @@ export function fromHex(hex: string): Uint8Array { } return out; } + 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..7a081e0 100644 --- a/packages/tlock/src/seal.test.ts +++ b/packages/tlock/src/seal.test.ts @@ -115,3 +115,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 0cfdf83..c235983 100644 --- a/packages/tlock/src/seal.ts +++ b/packages/tlock/src/seal.ts @@ -42,6 +42,13 @@ export function generateNonce(): Uint8Array { export async function sealBid(params: SealBidParams): Promise { const { value, nonce, round, client, identity, auditorPublicKey } = params; + 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); const h = commitment(value, nonce); const armored = await timelockEncrypt(round, TlockBuffer.from(preimage), client); @@ -68,6 +75,9 @@ export async function openBid( ciphertext: Uint8Array, client: DrandClient, ): Promise { + if (!ciphertext || ciphertext.length === 0) { + throw new Error("ciphertext is empty"); + } const armored = utf8Decode.decode(ciphertext); const plaintext = await timelockDecrypt(armored, client); return decodeBidPreimage(Uint8Array.from(plaintext));