diff --git a/packages/tlock/src/freshness.test.ts b/packages/tlock/src/freshness.test.ts index 4e803a0..9a3cae1 100644 --- a/packages/tlock/src/freshness.test.ts +++ b/packages/tlock/src/freshness.test.ts @@ -2,7 +2,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { classifyDrandRound, DEFAULT_STALE_THRESHOLD_MS } from "./freshness.js"; +import { classifyDrandRound, computePublishAtMs, DEFAULT_STALE_THRESHOLD_MS } from "./freshness.js"; test("freshness: missing or invalid round returns unknown", () => { const info = { genesis_time: 1677685200, period: 3 }; @@ -76,3 +76,32 @@ test("freshness: stale round", () => { // Custom threshold stale assert.equal(classifyDrandRound(round, info, 1030011, 10).status, "stale"); }); + +test("computePublishAtMs rejects unsafe round and period combinations", () => { + const info = { genesis_time: 1_000_000_000, period: 3 }; + + assert.equal(computePublishAtMs(info, Number.MAX_SAFE_INTEGER), null); + assert.equal( + computePublishAtMs({ genesis_time: Number.MAX_SAFE_INTEGER, period: 2 }, 1_000), + null, + ); + assert.equal(computePublishAtMs({ genesis_time: 0, period: 3 }, 1), 3000); +}); + +test("freshness: unsafe timestamp math returns unknown near MAX_SAFE_INTEGER", () => { + const info = { genesis_time: Number.MAX_SAFE_INTEGER - 1, period: 2 }; + const round = 2; + const now = 1_700_000_000_000; + + const res = classifyDrandRound(round, info, now); + assert.equal(res.status, "unknown"); + assert.match(String(res.reason ?? ""), /overflow|unsafe/i); +}); + +test("freshness: valid boundary round still classifies correctly", () => { + const info = { genesis_time: 1000, period: 1 }; + const round = 1_000_000; + const publishAtMs = computePublishAtMs(info, round); + assert.equal(publishAtMs, 1_001_000_000); + assert.equal(classifyDrandRound(round, info, publishAtMs!).status, "fresh"); +}); diff --git a/packages/tlock/src/freshness.ts b/packages/tlock/src/freshness.ts index 3792aba..9536acc 100644 --- a/packages/tlock/src/freshness.ts +++ b/packages/tlock/src/freshness.ts @@ -15,6 +15,39 @@ export interface FreshnessResult { ageMs?: number; } +/** + * Derives a Drand round publication timestamp in milliseconds, rejecting + * intermediate calculations that would exceed Number.MAX_SAFE_INTEGER. + */ +export function computePublishAtMs(info: DrandRoundInfo, round: number): number | null { + if (!Number.isSafeInteger(round) || round <= 0) { + return null; + } + if (!Number.isSafeInteger(info.period) || info.period <= 0) { + return null; + } + if (!Number.isSafeInteger(info.genesis_time) || info.genesis_time < 0) { + return null; + } + + const offsetSeconds = info.period * round; + if (!Number.isSafeInteger(offsetSeconds)) { + return null; + } + + const publishAtSeconds = info.genesis_time + offsetSeconds; + if (!Number.isSafeInteger(publishAtSeconds) || publishAtSeconds < 0) { + return null; + } + + const publishAtMs = publishAtSeconds * 1000; + if (!Number.isSafeInteger(publishAtMs)) { + return null; + } + + return publishAtMs; +} + /** * Classifies a Drand round's freshness deterministically using the current time * and Drand network info. @@ -41,8 +74,10 @@ export function classifyDrandRound( return { status: "unknown", reason: "invalid timestamp" }; } - // Compute publish time matching the existing keeper logic convention. - const publishAtMs = (info.genesis_time + info.period * round) * 1000; + const publishAtMs = computePublishAtMs(info, round); + if (publishAtMs == null) { + return { status: "unknown", reason: "timestamp overflow or unsafe calculation" }; + } if (nowMs < publishAtMs) { return {