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
4 changes: 2 additions & 2 deletions packages/tlock/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions packages/tlock/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export {

export { drandSignatureToSoroban, encodeG1Soroban } from "./bls.js";

export {
assertChainInfo,
assertBeacon,
type RawChainInfo,
type RawBeacon,
} from "./validate.js";

export {
sealBid,
openBid,
Expand Down
19 changes: 12 additions & 7 deletions packages/tlock/src/quicknet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand All @@ -21,15 +22,17 @@ 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).
export async function currentRound(
client: DrandClient,
unixMillis: number = systemClock.nowMs(),
): Promise<number> {
const info = await client.chain().info();
const info = await chainInfo(client);
return drandRoundAt(unixMillis, info);
}

Expand All @@ -40,23 +43,25 @@ export async function roundInSeconds(
seconds: number,
clock: Clock = systemClock,
): Promise<number> {
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<Uint8Array> {
const beacon = await fetchBeacon(client, round);
const beacon = await fetchRoundBeacon(client, round);
return drandSignatureToSoroban(beacon.signature);
}
127 changes: 127 additions & 0 deletions packages/tlock/src/validate.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void) {
const clone = JSON.parse(JSON.stringify(GOOD_CHAIN_INFO)) as Record<string, unknown>;
mutate(clone);
return clone;
}

function beaconClone(mutate: (c: Record<string, unknown>) => void) {
const clone = JSON.parse(JSON.stringify(GOOD_BEACON)) as Record<string, unknown>;
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/);
});
135 changes: 135 additions & 0 deletions packages/tlock/src/validate.ts
Original file line number Diff line number Diff line change
@@ -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");
}
}