Skip to content
Open
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
41 changes: 40 additions & 1 deletion packages/tlock/src/commitment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
encodeBidPreimage,
fromHex,
i128ToBeBytes,
isValidHex,
toHex,
} from "./commitment.js";

Expand Down Expand Up @@ -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([])), "");
});

19 changes: 17 additions & 2 deletions packages/tlock/src/commitment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -89,3 +103,4 @@ export function fromHex(hex: string): Uint8Array {
}
return out;
}

1 change: 1 addition & 0 deletions packages/tlock/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export {
beBytesToI128,
toHex,
fromHex,
isValidHex,
VALUE_BYTES,
NONCE_BYTES,
PREIMAGE_BYTES,
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 @@ -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/,
);
});

10 changes: 10 additions & 0 deletions packages/tlock/src/seal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ 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 < 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);
Expand All @@ -68,6 +75,9 @@ export async function openBid(
ciphertext: Uint8Array,
client: DrandClient,
): Promise<OpenedBid> {
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));
Expand Down