From a57b375e9cdbf7f41022de0e0931966dc0a682e1 Mon Sep 17 00:00:00 2001 From: merciiiqode Date: Mon, 31 Aug 2026 01:24:37 +0000 Subject: [PATCH] fix(tlock): validate drand quicknet response shapes before use (#280) --- packages/tlock/package.json | 4 +- packages/tlock/src/index.ts | 7 ++ packages/tlock/src/quicknet.ts | 19 ++-- packages/tlock/src/validate.test.ts | 127 ++++++++++++++++++++++++++ packages/tlock/src/validate.ts | 135 ++++++++++++++++++++++++++++ 5 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 packages/tlock/src/validate.test.ts create mode 100644 packages/tlock/src/validate.ts diff --git a/packages/tlock/package.json b/packages/tlock/package.json index e5ea2c28..f7244376 100644 --- a/packages/tlock/package.json +++ b/packages/tlock/package.json @@ -20,9 +20,9 @@ }, "main": "src/index.ts", "scripts": { - "test": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/seal.test.ts src/freshness.test.ts src/quicknet.test.ts", + "test": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/seal.test.ts src/freshness.test.ts src/quicknet.test.ts src/validate.test.ts", "test:quicknet": "node --import tsx --test src/quicknet.test.ts", - "test:unit": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/freshness.test.ts src/quicknet.test.ts", + "test:unit": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/freshness.test.ts src/quicknet.test.ts src/validate.test.ts", "test:seal": "node --import tsx --test src/seal.test.ts", "recover:identities": "node --import tsx src/recover-identities.cli.ts", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/tlock/src/index.ts b/packages/tlock/src/index.ts index 678fb122..05f8239c 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -34,6 +34,13 @@ export { export { drandSignatureToSoroban, encodeG1Soroban } from "./bls.js"; +export { + assertChainInfo, + assertBeacon, + type RawChainInfo, + type RawBeacon, +} from "./validate.js"; + export { sealBid, openBid, diff --git a/packages/tlock/src/quicknet.ts b/packages/tlock/src/quicknet.ts index f684dea0..e70982fa 100644 --- a/packages/tlock/src/quicknet.ts +++ b/packages/tlock/src/quicknet.ts @@ -9,6 +9,7 @@ import { } from "drand-client"; import { drandSignatureToSoroban } from "./bls.js"; +import { assertBeacon, assertChainInfo } from "./validate.js"; import { systemClock, type Clock } from "@sub-rosa/time"; export const QUICKNET_HASH = @@ -21,7 +22,9 @@ export function quicknet(): DrandClient { } export async function chainInfo(client: DrandClient) { - return client.chain().info(); + const info = await client.chain().info(); + assertChainInfo(info); + return info; } /// The round number live at `unixMillis` (defaults to now). @@ -29,7 +32,7 @@ export async function currentRound( client: DrandClient, unixMillis: number = systemClock.nowMs(), ): Promise { - const info = await client.chain().info(); + const info = await chainInfo(client); return drandRoundAt(unixMillis, info); } @@ -40,23 +43,25 @@ export async function roundInSeconds( seconds: number, clock: Clock = systemClock, ): Promise { - const info = await client.chain().info(); + const info = await chainInfo(client); return drandRoundAt(clock.nowMs() + seconds * 1000, info); } /// The raw beacon (round, randomness, signature hex) for a specific round. -/// Rejects if round R has not yet been published. +/// Rejects if round R has not yet been published or the response is malformed. export async function fetchRoundBeacon(client: DrandClient, round: number) { - return fetchBeacon(client, round); + const beacon = await fetchBeacon(client, round); + assertBeacon(beacon); + return beacon; } /// Round R's threshold signature, encoded as the 96-byte uncompressed G1 the /// Round contract verifies on-chain. This is exactly the value `open_reveal` -/// takes. Rejects if R has not been published yet. +/// takes. Rejects if R has not been published yet or the response is malformed. export async function fetchRoundSignature( client: DrandClient, round: number, ): Promise { - const beacon = await fetchBeacon(client, round); + const beacon = await fetchRoundBeacon(client, round); return drandSignatureToSoroban(beacon.signature); } diff --git a/packages/tlock/src/validate.test.ts b/packages/tlock/src/validate.test.ts new file mode 100644 index 00000000..9e500a04 --- /dev/null +++ b/packages/tlock/src/validate.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Sub Rosa contributors +// Focused unit tests for Drand quicknet response-shape validation. These are +// fully offline — no HTTP, no real Drand network. They exercise +// `assertChainInfo` / `assertBeacon` directly against valid and malformed +// fixtures, proving cryptographic consumers are guarded before use. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { assertBeacon, assertChainInfo } from "./validate.js"; + +const GOOD_CHAIN_INFO = { + public_key: + "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a", + period: 3, + genesis_time: 1692803367, + hash: "52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971", + groupHash: "f477d5c89f21a17c863a7f937c6a6d15859414d2be09cd448d4279af331c5d3e", + schemeID: "bls-unchained-g1-rfc9380", + metadata: { beaconID: "quicknet" }, +}; + +const GOOD_BEACON = { + round: 29_155_653, + randomness: + "3e9f1c2c6ab0f7c0f9b0a44bbd14cf0a3d2b3a6b1e4f5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b10", + signature: + "8c8c0e1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f10", +}; + +function chainClone(mutate: (c: Record) => void) { + const clone = JSON.parse(JSON.stringify(GOOD_CHAIN_INFO)) as Record; + mutate(clone); + return clone; +} + +function beaconClone(mutate: (c: Record) => void) { + const clone = JSON.parse(JSON.stringify(GOOD_BEACON)) as Record; + mutate(clone); + return clone; +} + +test("assertChainInfo accepts a well-formed quicknet chain-info", () => { + assert.doesNotThrow(() => assertChainInfo(GOOD_CHAIN_INFO)); +}); + +test("assertChainInfo rejects a non-object", () => { + assert.throws(() => assertChainInfo(undefined), /chain-info/); + assert.throws(() => assertChainInfo(null), /chain-info/); + assert.throws(() => assertChainInfo("nope"), /chain-info/); +}); + +test("assertChainInfo rejects a missing public_key", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.public_key)), /public_key/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.public_key = ""; })), /public_key/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.public_key = "not-hex!!"; })), /public_key/); +}); + +test("assertChainInfo rejects a non-positive or missing period", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.period)), /period/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.period = 0; })), /period/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.period = -3; })), /period/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.period = 1.5; })), /period/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.period = "3"; })), /period/); +}); + +test("assertChainInfo rejects an out-of-range period", () => { + assert.throws(() => assertChainInfo(chainClone((c) => { c.period = 1001; })), /period/); +}); + +test("assertChainInfo rejects a missing or out-of-range genesis_time", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.genesis_time)), /genesis_time/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.genesis_time = 0; })), /genesis_time/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.genesis_time = -1; })), /genesis_time/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.genesis_time = 1234; })), /genesis_time/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.genesis_time = 2_100_000_001; })), /genesis_time/); +}); + +test("assertChainInfo rejects a malformed chain hash", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.hash)), /hash/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.hash = "deadbeef"; })), /hash/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.hash = `${"a".repeat(63)}z`; })), /hash/); +}); + +test("assertChainInfo rejects a missing groupHash or schemeID", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.groupHash)), /groupHash/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.groupHash = "!!"; })), /groupHash/); + assert.throws(() => assertChainInfo(chainClone((c) => delete c.schemeID)), /schemeID/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.schemeID = ""; })), /schemeID/); +}); + +test("assertChainInfo rejects a missing metadata beaconID", () => { + assert.throws(() => assertChainInfo(chainClone((c) => delete c.metadata)), /beaconID/); + assert.throws(() => + assertChainInfo(chainClone((c) => { (c.metadata as { beaconID?: unknown }).beaconID = ""; })), + /beaconID/); + assert.throws(() => assertChainInfo(chainClone((c) => { c.metadata = { beaconID: "" }; })), /beaconID/); +}); + +test("assertBeacon accepts a well-formed beacon", () => { + assert.doesNotThrow(() => assertBeacon(GOOD_BEACON)); +}); + +test("assertBeacon rejects a non-object", () => { + assert.throws(() => assertBeacon(undefined), /beacon/); + assert.throws(() => assertBeacon(null), /beacon/); + assert.throws(() => assertBeacon(42), /beacon/); +}); + +test("assertBeacon rejects a missing or non-positive round", () => { + assert.throws(() => assertBeacon(beaconClone((c) => delete c.round)), /round/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.round = 0; })), /round/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.round = -1; })), /round/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.round = 1.5; })), /round/); +}); + +test("assertBeacon rejects a missing or non-hex randomness", () => { + assert.throws(() => assertBeacon(beaconClone((c) => delete c.randomness)), /randomness/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.randomness = ""; })), /randomness/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.randomness = "zz"; })), /randomness/); +}); + +test("assertBeacon rejects a missing or non-hex signature (guards crypto)", () => { + assert.throws(() => assertBeacon(beaconClone((c) => delete c.signature)), /signature/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.signature = ""; })), /signature/); + assert.throws(() => assertBeacon(beaconClone((c) => { c.signature = "not-a-sig"; })), /signature/); +}); diff --git a/packages/tlock/src/validate.ts b/packages/tlock/src/validate.ts new file mode 100644 index 00000000..cafee2d9 --- /dev/null +++ b/packages/tlock/src/validate.ts @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Sub Rosa contributors +// Validation for the Drand quicknet HTTP response shapes — chain-info and +// per-round beacons — *before* any cryptographic code touches them. +// +// Drand endpoints return JSON that is not structurally guaranteed. If a proxy +// or replica serves a malformed body, downstream crypto (`drandRoundAt`, +// `drandSignatureToSoroban`, the keeper's round-wait math) would fail with a +// cryptic error or, worse, compute against garbage. These guards reject a bad +// response up-front with an explicit, actionable message. + +import { isValidHex } from "./commitment.js"; + +/** Bound the range of sane values for a quicknet-scale chain. */ +const MIN_PERIOD = 1; +const MAX_PERIOD = 1000; +const MIN_GENESIS_TIME = 1_000_000_000; // after Sept 2001 +const MAX_GENESIS_TIME = 2_100_000_000; // before 2036 +const CHAIN_HASH_HEX_LEN = 64; // SHA-256 digest + +export interface RawChainInfo { + public_key?: unknown; + period?: unknown; + genesis_time?: unknown; + hash?: unknown; + groupHash?: unknown; + schemeID?: unknown; + metadata?: { beaconID?: unknown }; +} + +export interface RawBeacon { + round?: unknown; + randomness?: unknown; + signature?: unknown; +} + +function fail(field: string, detail: string): never { + throw new Error(`malformed drand chain-info: ${field} ${detail}`); +} + +function beaconFail(field: string, detail: string): never { + throw new Error(`malformed drand beacon: ${field} ${detail}`); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function isSafeNumber(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value); +} + +/** + * Validate a Drand chain-info object. Throws a descriptive {@link Error} when a + * required field is missing or a numeric field falls outside its sane range. + * Returns `void` (the object itself is trusted) on success. + */ +export function assertChainInfo(info: unknown): void { + if (!info || typeof info !== "object") { + throw new Error("malformed drand chain-info: expected an object"); + } + const c = info as RawChainInfo; + + if (!isNonEmptyString(c.public_key) || !isValidHex(c.public_key)) { + fail("public_key", "must be a non-empty hex string"); + } + + if (!isSafeNumber(c.period) || c.period < MIN_PERIOD || c.period > MAX_PERIOD) { + fail( + "period", + `must be an integer in [${MIN_PERIOD}, ${MAX_PERIOD}] seconds, got ${String(c.period)}`, + ); + } + + if ( + !isSafeNumber(c.genesis_time) || + c.genesis_time < MIN_GENESIS_TIME || + c.genesis_time > MAX_GENESIS_TIME + ) { + fail( + "genesis_time", + `must be an integer in [${MIN_GENESIS_TIME}, ${MAX_GENESIS_TIME}] (unix seconds), got ${String(c.genesis_time)}`, + ); + } + + if ( + !isNonEmptyString(c.hash) || + !isValidHex(c.hash) || + c.hash.length !== CHAIN_HASH_HEX_LEN + ) { + fail( + "hash", + `must be a ${CHAIN_HASH_HEX_LEN}-char hex string (SHA-256 digest)`, + ); + } + + if (!isNonEmptyString(c.groupHash) || !isValidHex(c.groupHash)) { + fail("groupHash", "must be a non-empty hex string"); + } + + if (!isNonEmptyString(c.schemeID)) { + fail("schemeID", "must be a non-empty string"); + } + + if (!c.metadata || typeof c.metadata !== "object") { + fail("metadata.beaconID", "metadata must be present"); + } + if (!isNonEmptyString(c.metadata?.beaconID)) { + fail("metadata.beaconID", "must be a non-empty string"); + } +} + +/** + * Validate a Drand beacon (round randomness) object. Throws a descriptive + * {@link Error} when a required field is missing or malformed. Returns `void` + * on success. The caller is still responsible for rejecting beacons for rounds + * that have not been published yet. + */ +export function assertBeacon(beacon: unknown): void { + if (!beacon || typeof beacon !== "object") { + throw new Error("malformed drand beacon: expected an object"); + } + const b = beacon as RawBeacon; + + if (!isSafeNumber(b.round) || b.round <= 0) { + beaconFail("round", `must be a positive integer, got ${String(b.round)}`); + } + + if (!isNonEmptyString(b.randomness) || !isValidHex(b.randomness)) { + beaconFail("randomness", "must be a non-empty hex string"); + } + + if (!isNonEmptyString(b.signature) || !isValidHex(b.signature)) { + beaconFail("signature", "must be a non-empty hex string"); + } +}