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
39 changes: 38 additions & 1 deletion packages/tlock/src/commitment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
19 changes: 13 additions & 6 deletions packages/tlock/src/commitment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
51 changes: 51 additions & 0 deletions packages/tlock/src/seal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/,
);
});

12 changes: 7 additions & 5 deletions packages/tlock/src/seal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,11 @@ export function generateNonce(): Uint8Array {
export async function sealBid(params: SealBidParams): Promise<SealedBid> {
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);
Expand Down Expand Up @@ -72,10 +75,9 @@ export async function openBid(
ciphertext: Uint8Array,
client: DrandClient,
): Promise<OpenedBid> {
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));
Expand Down
Loading