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
15 changes: 15 additions & 0 deletions apps/loopover-ui/content/docs/verify-this-review.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,19 @@ verified.
[#8136](https://github.com/JSONbored/loopover/issues/8136) and
[#8137](https://github.com/JSONbored/loopover/issues/8137). This walkthrough is honest about
stopping at the reproducibility line rather than implying the stronger guarantee.

**Live decision execution is permanently out of scope for attestation, by decision.** The
attested-evaluation epic ([#8534](https://github.com/JSONbored/loopover/issues/8534)) attests the
*offline* backtest replay only. Attesting the *live* gate-decision path (the merge/close calls
that actually act on a PR) was evaluated and declined —
[#9141](https://github.com/JSONbored/loopover/issues/9141) — because a network-reachable
attestation service in the live request path trades a real availability guarantee for a proof the
deterministic rule stage already gives more cheaply through replay
([#8832](https://github.com/JSONbored/loopover/issues/8832),
[#8838](https://github.com/JSONbored/loopover/issues/8838)), and because attestation proves
*computation*, not the decision record's own honesty — the actual gap a skeptical verifier cares
about, closed instead by an externally anchored, complete, replayable decision record. What "not
proved" above means in practice: every live decision's provenance rests on the decision-record +
replay + ledger-anchoring trust surface, never on attested execution — and that is not a temporary
gap awaiting hardware, it is the standing design.
</Callout>
109 changes: 100 additions & 9 deletions packages/loopover-engine/src/calibration/attestation-envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@
// separate maintainer work in the parent epic -- doing any of it here would be unreviewable scope and would
// bake a verification policy into what is meant to be a transport shape. Same purity contract as the rest of
// this module family: no IO, no randomness, no wall-clock reads.
//
// #9140 (schemaVersion 1, in place before any envelope is persisted -- #8537 has not shipped yet, so this is
// a pre-launch correction, not a breaking migration): two gaps fixed together.
// 1. `reportData` was a bare 32-byte SHA-256 hex digest, but SEV-SNP's `REPORT_DATA` field and TDX's
// `REPORTDATA` are both 64 BYTES (128 hex chars) of guest-supplied data -- the digest has to be PLACED
// into a field twice its width, and nothing specified how. See {@link buildAttestationReportData}'s
// doc comment for the finalized layout + worked test vectors.
// 2. The binding (`corpusChecksum:headSha:baseSha`) is deterministic across runs -- the same corpus at the
// same SHAs always produces the same `reportData`, so one genuine attested run's report could be
// presented for any later run with an identical binding. The second half of the now-64-byte field is a
// freshness component (a nonce or monotonic run id) tying the report to exactly one run; the envelope's
// new `runId` field carries the plaintext value a verifier checks it against.

import { createHash } from "node:crypto";

Expand All @@ -30,8 +42,15 @@ export type AttestationEnvelope = {
runtimeClass: string;
/** Launch measurement, lowercase hex, 32-128 hex chars (widths differ per TEE technology). */
measurement: string;
/** sha256, lowercase hex, exactly 64 chars -- see {@link buildAttestationReportData} for the binding. */
/** The 64-byte REPORT_DATA/REPORTDATA payload, lowercase hex, exactly 128 hex chars -- see
* {@link buildAttestationReportData} for the binding + freshness layout. */
reportData: string;
/** The freshness component bound into `reportData`'s second half (as `sha256(runId)`) -- a nonce or
* monotonic run id, plaintext here so a verifier can recompute `sha256(runId)` and check it against
* `reportData.slice(64)` AND against whatever run identifier they expect (e.g. the backtest run row this
* evidence claims to belong to), which is what stops a genuinely-signed but STALE report from being
* presented for a different run. Lowercase hex, 1-128 chars -- same shape family as `measurement`. */
runId: string;
/** The raw attestation report, base64, non-empty and <= 65536 chars. Never parsed here. */
attestationReport: string;
verification: AttestationVerification;
Expand All @@ -41,17 +60,25 @@ const TEE_TECHNOLOGIES: readonly string[] = ["sev-snp", "tdx"];
const RUNTIME_CLASS_MAX = 128;
const MEASUREMENT_MIN_HEX = 32;
const MEASUREMENT_MAX_HEX = 128;
const REPORT_DATA_HEX = 64;
/** #9140: 128 hex chars = 64 bytes, matching SEV-SNP `REPORT_DATA` / TDX `REPORTDATA` exactly (was 64/32,
* half the required width -- see this module's header comment). */
const REPORT_DATA_HEX = 128;
const RUN_ID_MIN_HEX = 1;
const RUN_ID_MAX_HEX = 128;
const ATTESTATION_REPORT_MAX = 65536;
const LOWERCASE_HEX = /^[0-9a-f]+$/;
const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/;
// #9140: the old `/^[A-Za-z0-9+/]+={0,2}$/` accepted a string whose length was not a multiple of 4 (e.g. 5
// base64 characters), which is not valid base64 under any padding rule. This form requires the body to be
// whole groups of 4 characters, with at most one trailing padded group (2 chars + `==`, or 3 chars + `=`).
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;
const ENVELOPE_KEYS: readonly string[] = [
"schemaVersion",
"teeTechnology",
"runtimeClass",
"measurement",
"reportData",
"runId",
"attestationReport",
"verification",
];
Expand All @@ -61,14 +88,68 @@ const VERIFICATION_KEYS: Record<AttestationVerification["status"], readonly stri
failed: ["status", "verifierId", "verifiedAt", "reason"],
};

/** The three commitment inputs {@link buildAttestationReportData} binds, plus the freshness component.
* `corpusChecksum` is a sha256 hex digest (64 chars, matching `BacktestCorpusManifest.checksum` /
* `checksumCases`'s own output shape); `headSha`/`baseSha` are git object shas (40 hex today under SHA-1,
* 64 once a repo moves to SHA-256); `runId` is the caller's own freshness token -- a nonce or monotonic run
* id -- hex-encoded by the caller in whatever way is convenient (a UUID's hex form, a hex-encoded counter,
* raw random bytes). Constraining all four to their expected hex shapes (rather than joining raw,
* unconstrained strings with `:`) is what makes the binding unambiguous: a `:` cannot appear inside a
* validated hex string, so no input can inject a colon and make two different (checksum, sha, sha, runId)
* quadruples collide on the same joined text. */
export type AttestationReportDataBinding = {
corpusChecksum: string;
headSha: string;
baseSha: string;
runId: string;
};

const CORPUS_CHECKSUM_SHAPE = /^[0-9a-f]{64}$/;
const GIT_SHA_SHAPE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
const RUN_ID_SHAPE = /^[0-9a-f]{1,128}$/;

function requireShape(value: string, shape: RegExp, field: string, expected: string): void {
if (typeof value !== "string" || !shape.test(value)) {
throw new Error(`buildAttestationReportData: ${field} must be ${expected}`);
}
}

/**
* The 32-byte payload a TEE binds into its attestation report, as lowercase hex: sha256 of
* `${corpusChecksum}:${headSha}:${baseSha}`. Binding all three is what makes the report prove WHICH
* evaluation ran (#8136) -- the corpus alone would not pin the code revision, and the SHAs alone would not
* pin the data. Mirrors backtest-split.ts's own `createHash("sha256")` usage; no new dependency.
* The 64-byte REPORT_DATA/REPORTDATA payload a TEE binds into its attestation report, as 128 lowercase hex
* chars: `sha256(corpusChecksum:headSha:baseSha) || sha256(runId)`.
*
* The first 32 bytes commit to WHICH evaluation ran (#8136) -- the corpus alone would not pin the code
* revision, and the SHAs alone would not pin the data. The second 32 bytes are the freshness component
* (#9140): hashing `runId` (rather than embedding it raw) normalizes any caller-chosen hex encoding to a
* fixed 32-byte width, matching the first half's shape. A verifier recomputes both halves independently and
* checks the SECOND half's plaintext preimage (`runId`, published in the envelope) against whatever run
* identifier they expect -- that is what stops a validly-signed but STALE report (same corpus/shas, an old
* run) from being presented for a different run: a fresh run mints a fresh `runId`, so its `reportData`
* differs in its entirety even though the corpus/shas half is unchanged.
*
* Throws (loudly, rather than silently producing an ambiguous or malformed commitment -- same posture as
* `canonicalJson` elsewhere in this codebase) when any input is not shaped as documented on
* {@link AttestationReportDataBinding}.
*
* Worked example (test vector, also asserted verbatim in this module's test suite):
* ```
* corpusChecksum = "a1".repeat(32) // 64 hex chars
* headSha = "b2".repeat(20) // 40 hex chars
* baseSha = "c3".repeat(20) // 40 hex chars
* runId = "d4".repeat(16) // 32 hex chars
* reportData =
* "3309f8c4eadab7422c8b5ba378a12d52b5676f601faa4dcbac213bae93f5ae7e" +
* "1d88ffa7d3cf1f07e5cf64b62016f3e688ad473286f2f613886b6ac02d00541d"
* ```
*/
export function buildAttestationReportData(binding: { corpusChecksum: string; headSha: string; baseSha: string }): string {
return createHash("sha256").update(`${binding.corpusChecksum}:${binding.headSha}:${binding.baseSha}`).digest("hex");
export function buildAttestationReportData(binding: AttestationReportDataBinding): string {
requireShape(binding.corpusChecksum, CORPUS_CHECKSUM_SHAPE, "corpusChecksum", "64 lowercase hex characters");
requireShape(binding.headSha, GIT_SHA_SHAPE, "headSha", "40 or 64 lowercase hex characters");
requireShape(binding.baseSha, GIT_SHA_SHAPE, "baseSha", "40 or 64 lowercase hex characters");
requireShape(binding.runId, RUN_ID_SHAPE, "runId", "1-128 lowercase hex characters");
const bindingDigest = createHash("sha256").update(`${binding.corpusChecksum}:${binding.headSha}:${binding.baseSha}`).digest("hex");
const freshnessDigest = createHash("sha256").update(binding.runId).digest("hex");
return `${bindingDigest}${freshnessDigest}`;
}

function nonEmptyString(value: unknown): value is string {
Expand Down Expand Up @@ -149,6 +230,16 @@ export function validateAttestationEnvelope(
errors.push(`reportData: expected exactly ${REPORT_DATA_HEX} lowercase hex characters`);
}

const runId = record["runId"];
if (
typeof runId !== "string" ||
!LOWERCASE_HEX.test(runId) ||
runId.length < RUN_ID_MIN_HEX ||
runId.length > RUN_ID_MAX_HEX
) {
errors.push(`runId: expected ${RUN_ID_MIN_HEX}-${RUN_ID_MAX_HEX} lowercase hex characters`);
}

const attestationReport = record["attestationReport"];
if (
!nonEmptyString(attestationReport) ||
Expand Down
91 changes: 84 additions & 7 deletions packages/loopover-engine/test/attestation-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,68 @@ const BASE = {
teeTechnology: "sev-snp",
runtimeClass: "loopover-backtest-runner",
measurement: "a".repeat(64),
reportData: "b".repeat(64),
reportData: "b".repeat(128),
runId: "d4".repeat(16),
attestationReport: "QUJD",
verification: { status: "unverified" },
};

// #9140 worked example, asserted verbatim (also published in buildAttestationReportData's own doc comment as
// the spec a non-JS verifier reimplements against).
const CORPUS_CHECKSUM = "a1".repeat(32); // 64 hex
const HEAD_SHA = "b2".repeat(20); // 40 hex
const BASE_SHA = "c3".repeat(20); // 40 hex
const RUN_ID = "d4".repeat(16); // 32 hex
const EXPECTED_REPORT_DATA =
"3309f8c4eadab7422c8b5ba378a12d52b5676f601faa4dcbac213bae93f5ae7e" +
"1d88ffa7d3cf1f07e5cf64b62016f3e688ad473286f2f613886b6ac02d00541d";

test("barrel: the public entrypoint re-exports the attestation-envelope primitives (#8541)", () => {
assert.equal(typeof buildAttestationReportData, "function");
assert.equal(typeof validateAttestationEnvelope, "function");
});

test("buildAttestationReportData pins the corpusChecksum:headSha:baseSha binding (#8541)", () => {
assert.equal(
buildAttestationReportData({ corpusChecksum: "abc123", headSha: "head456", baseSha: "base789" }),
"fcc875115df49bf143b18fc8a8071e9a946858407fabf61e59ef1607d1cfb140",
);
test("buildAttestationReportData: the published test vector (#9140) — sha256(binding) || sha256(runId), 128 hex chars", () => {
const reportData = buildAttestationReportData({ corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID });
assert.equal(reportData, EXPECTED_REPORT_DATA);
assert.equal(reportData.length, 128);
});

test("buildAttestationReportData: injective over the (corpusChecksum, headSha, baseSha, runId) quadruple", () => {
const base = { corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID };
const variants = [
base,
{ ...base, corpusChecksum: "a2".repeat(32) },
{ ...base, headSha: "b3".repeat(20) },
{ ...base, baseSha: "c4".repeat(20) },
{ ...base, runId: "d5".repeat(16) },
// A different git-sha WIDTH (64-hex SHA-256 git object ids) must still be a distinct commitment.
{ ...base, headSha: "b2".repeat(32) },
];
const digests = variants.map((binding) => buildAttestationReportData(binding));
assert.equal(new Set(digests).size, digests.length, "every varied input must produce a distinct reportData");
});

test("buildAttestationReportData: the freshness half is independently addressable — only runId moves the second 64 hex chars", () => {
const base = { corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID };
const baseReportData = buildAttestationReportData(base);
const freshRun = buildAttestationReportData({ ...base, runId: "d5".repeat(16) });
assert.equal(freshRun.slice(0, 64), baseReportData.slice(0, 64), "the binding half is unaffected by a run-id change");
assert.notEqual(freshRun.slice(64), baseReportData.slice(64), "the freshness half MUST change so a stale report cannot be replayed for a new run");

const rebound = buildAttestationReportData({ ...base, corpusChecksum: "a2".repeat(32) });
assert.notEqual(rebound.slice(0, 64), baseReportData.slice(0, 64), "the binding half moves when the corpus/shas change");
assert.equal(rebound.slice(64), baseReportData.slice(64), "the freshness half is unaffected by a binding-only change");
});

test("buildAttestationReportData: throws (never silently mis-commits) on a malformed input shape", () => {
const base = { corpusChecksum: CORPUS_CHECKSUM, headSha: HEAD_SHA, baseSha: BASE_SHA, runId: RUN_ID };
assert.throws(() => buildAttestationReportData({ ...base, corpusChecksum: "abc123" }), /corpusChecksum/);
assert.throws(() => buildAttestationReportData({ ...base, corpusChecksum: `${CORPUS_CHECKSUM.slice(0, 63)}:` }), /corpusChecksum/);
assert.throws(() => buildAttestationReportData({ ...base, headSha: "not-hex-and-wrong-length" }), /headSha/);
assert.throws(() => buildAttestationReportData({ ...base, baseSha: "" }), /baseSha/);
assert.throws(() => buildAttestationReportData({ ...base, runId: "UPPER" }), /runId/);
assert.throws(() => buildAttestationReportData({ ...base, runId: "" }), /runId/);
});

test("validateAttestationEnvelope accepts a well-formed envelope and each verification variant (#8541)", () => {
Expand All @@ -41,9 +88,39 @@ test("validateAttestationEnvelope accepts a well-formed envelope and each verifi
});

test("validateAttestationEnvelope rejects structurally invalid input without throwing (#8541)", () => {
for (const bad of [null, undefined, 42, "envelope", [], { ...BASE, schemaVersion: 2 }, { ...BASE, reportData: "b".repeat(63) }, { ...BASE, rogue: 1 }]) {
for (const bad of [
null,
undefined,
42,
"envelope",
[],
{ ...BASE, schemaVersion: 2 },
{ ...BASE, reportData: "b".repeat(63) }, // #9140: pre-fix width (32 bytes) is now rejected
{ ...BASE, reportData: "b".repeat(127) },
{ ...BASE, rogue: 1 },
]) {
const result = validateAttestationEnvelope(bad);
assert.equal(result.valid, false);
assert.ok(Array.isArray(result.errors) && result.errors.length > 0);
}
});

// #9140: the freshness envelope field — must be present, hex-shaped, and bounded like `measurement`.
test("validateAttestationEnvelope: runId is required, hex, and bounded 1-128 chars", () => {
assert.equal(validateAttestationEnvelope({ ...BASE, runId: undefined }).valid, false);
const missing = { schemaVersion: BASE.schemaVersion, teeTechnology: BASE.teeTechnology, runtimeClass: BASE.runtimeClass, measurement: BASE.measurement, reportData: BASE.reportData, attestationReport: BASE.attestationReport, verification: BASE.verification };
assert.equal(validateAttestationEnvelope(missing).valid, false);
assert.equal(validateAttestationEnvelope({ ...BASE, runId: "NOTHEX" }).valid, false);
assert.equal(validateAttestationEnvelope({ ...BASE, runId: "" }).valid, false);
assert.equal(validateAttestationEnvelope({ ...BASE, runId: "a".repeat(129) }).valid, false);
assert.equal(validateAttestationEnvelope({ ...BASE, runId: "a" }).valid, true);
});

// #9140: the old regex accepted any run of base64-alphabet characters regardless of length, including widths
// that are not valid base64 under any padding rule (e.g. 5 characters can never be a whole base64 payload).
test("validateAttestationEnvelope: attestationReport must be valid base64 — length a multiple of 4", () => {
assert.equal(validateAttestationEnvelope({ ...BASE, attestationReport: "QUJDQ" }).valid, false); // 5 chars
assert.equal(validateAttestationEnvelope({ ...BASE, attestationReport: "QUJDQQ" }).valid, false); // 6 chars, no padding
assert.equal(validateAttestationEnvelope({ ...BASE, attestationReport: "QUJDQQ==" }).valid, true); // properly padded
assert.equal(validateAttestationEnvelope({ ...BASE, attestationReport: "QUJD" }).valid, true); // exact multiple of 4
});
2 changes: 1 addition & 1 deletion scripts/backfill-decision-labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export async function buildBundle(rows: CandidateRow[], stagedAt: string): Promi
action: target.stratum === "close_arm" ? "close" : "hold",
reasonCode: target.reasonCode,
configDigest: "backfill:unavailable",
modelId: null,
modelIds: null,
promptDigest: null,
aiConfidence: target.aiConfidence,
decidedAt: target.decidedAt,
Expand Down
11 changes: 10 additions & 1 deletion scripts/replay-decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@
//
// The bundle is one JSON object: { record: {...decision_records row}, replayInput: {...replay_json} }.
// EXTRACT (operator, against the instance DB):
// SELECT json_build_object('record', to_jsonb(dr), 'replayInput', dri.replay_json::jsonb)
// SELECT json_build_object(
// -- #9135: `|| dr.record_json::jsonb` overlays every field the PUBLIC record carries (including
// -- camelCase fields that live only inside record_json, e.g. `divertedByHoldout`) on top of the
// -- raw snake_case row columns, so a field added to the record schema needs no matching change
// -- here to reach the CLI.
// 'record', to_jsonb(dr) || dr.record_json::jsonb,
// 'replayInput', dri.replay_json::jsonb
// )
// FROM decision_records dr JOIN decision_replay_inputs dri ON dri.record_id = dr.id
// WHERE dr.id = 'record:<owner/repo>#<pr>@<head sha>';
//
Expand All @@ -33,6 +40,8 @@ export function runReplayBundle(raw: string): { outcome: ReturnType<typeof repla
id: rawRecord.id,
reasonCode: String(rawRecord.reasonCode ?? rawRecord.reason_code ?? ""),
action: String(rawRecord.action ?? ""),
// #9135: absent for a pre-#9135 record — normalizes to false, matching DecisionRecord's own default.
divertedByHoldout: Boolean(rawRecord.divertedByHoldout ?? rawRecord.diverted_by_holdout ?? false),
}
: null;
if (!record || !replayInput || !Array.isArray(replayInput.findings) || typeof replayInput.evaluated !== "object") {
Expand Down
Loading
Loading