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
149 changes: 149 additions & 0 deletions packages/loopover-engine/src/calibration/attester.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// Attester seam (#9211) -- the pluggable boundary between "collect an attestation report" and "assemble the
// evidence envelope", so the attested-evaluation epic (#8534) can be built and CI-tested BEFORE any
// SEV-SNP-capable hardware exists. Confidential Containers ships a no-TEE sample-attester path for exactly
// this reason; mirroring it here means real hardware arrives as a CONFIG change (swap the Attester
// implementation) rather than as new code on the critical path.
//
// The split follows this module family's pure-core / thin-IO discipline (see threshold-backtest.ts vs
// services/threshold-backtest-run.ts): the interface, the deterministic sample implementation, and envelope
// assembly are pure and live here; a real SEV-SNP attester talks to /dev/sev-guest or a CoCo attestation
// agent and therefore lives in the I/O layer that supplies it. No IO, no randomness, no wall-clock reads --
// the sample attester derives every byte it returns from its request, so a sample run is reproducible.
//
// SAFETY: a sample-attested envelope must never be presentable as real evidence. Two independent things stop
// that: the report carries {@link SAMPLE_ATTESTATION_MAGIC} as its leading plaintext bytes (so a verifier can
// name it precisely instead of reporting a confusing signature failure), AND a sample report has no AMD
// signature chain, so cryptographic verification fails regardless. The magic is the honest error message; the
// missing chain is the actual guarantee.

import { createHash } from "node:crypto";

import {
buildAttestationReportData,
validateAttestationEnvelope,
type AttestationEnvelope,
type AttestationReportDataBinding,
type AttestationTeeTechnology,
} from "./attestation-envelope.js";

/**
* Leading plaintext bytes of every report {@link createSampleAttester} produces. Deliberately ASCII and
* deliberately not a valid SEV-SNP report prefix (a real report opens with a little-endian u32 version
* field, never this text), so detection is unambiguous in both directions.
*/
export const SAMPLE_ATTESTATION_MAGIC = "LOOPOVER-SAMPLE-ATTESTATION-v1";

/** What an attester is asked to bind and report over. `reportData` is {@link buildAttestationReportData}'s
* output (128 hex chars); `runtimeClass` labels the runtime image the workload ran as. */
export type AttestationCollectionRequest = {
reportData: string;
runtimeClass: string;
};

/** What an attester returns -- the evidence-bearing fields only. The binding fields (`reportData`, `runId`)
* come from the caller, not the attester, so an attester can never restate what a run committed to. */
export type AttestationCollection = {
teeTechnology: AttestationTeeTechnology;
measurement: string;
attestationReport: string;
};

/**
* The swappable boundary. `kind` is a stable identifier for the implementation ("sample", "sev-snp", ...)
* used in logs and failure detail; `collect` is async because every real implementation performs IO.
*/
export type Attester = {
kind: string;
collect(request: AttestationCollectionRequest): Promise<AttestationCollection>;
};

export type SampleAttesterOptions = {
/** Which TEE the sample impersonates, so downstream shape handling is exercised for both. Default "sev-snp". */
teeTechnology?: AttestationTeeTechnology;
};

/**
* A deterministic, hardware-free {@link Attester} for development and CI. Every field is derived from the
* request by SHA-256, so the same request always yields the same collection -- which is what lets the
* attested-run E2E assert exact bytes instead of merely "something was produced".
*
* NOT evidence of anything. See this module's SAFETY note.
*/
export function createSampleAttester(options: SampleAttesterOptions = {}): Attester {
const teeTechnology = options.teeTechnology ?? "sev-snp";
return {
kind: "sample",
collect(request: AttestationCollectionRequest): Promise<AttestationCollection> {
const digest = createHash("sha256")
.update(`${SAMPLE_ATTESTATION_MAGIC}:${request.reportData}:${request.runtimeClass}`)
.digest("hex");
return Promise.resolve({
teeTechnology,
// 64 hex chars -- inside the envelope's 32-128 measurement window for either technology.
measurement: createHash("sha256").update(`sample-measurement:${request.runtimeClass}`).digest("hex"),
attestationReport: Buffer.from(`${SAMPLE_ATTESTATION_MAGIC}:${digest}`, "utf8").toString("base64"),
});
},
};
}

/**
* True when `attestationReport` (base64) carries {@link SAMPLE_ATTESTATION_MAGIC}. Never throws: malformed
* base64, empty input and non-strings all return false, because a caller asking "is this a dev artifact?"
* about unparseable bytes wants `false`, not an exception -- the real verifier rejects those on their own
* merits (no signature chain) with its own error.
*/
export function isSampleAttestationReport(attestationReport: unknown): boolean {
if (typeof attestationReport !== "string" || attestationReport.length === 0) return false;
// Buffer.from(..., "base64") is lenient (it ignores invalid characters rather than throwing), so a decode
// that "succeeds" on garbage still can't produce the magic -- the prefix check below is what decides.
return Buffer.from(attestationReport, "base64").toString("utf8").startsWith(SAMPLE_ATTESTATION_MAGIC);
}

export type AttestationAssemblyResult =
| { ok: true; envelope: AttestationEnvelope }
| { ok: false; errors: string[] };

/**
* Bind a run, collect evidence for it, and assemble a structurally-valid {@link AttestationEnvelope}.
* Computes `reportData` from `binding` itself rather than trusting a caller-supplied value, so the envelope's
* commitment and the bytes the attester signed over cannot drift apart.
*
* Returns `{ ok: false }` -- never throws -- when the attester's collection fails to assemble into a valid
* envelope, so a runner can record `attestation_failed` instead of crashing a completed evaluation. A
* throwing attester (real hardware absent, agent unreachable) is likewise converted into `{ ok: false }`:
* fail-closed is the runner's decision to record, not a stack trace to propagate. Binding-shape errors from
* {@link buildAttestationReportData} are the deliberate exception -- those are programmer errors in the
* caller's own inputs, and throwing loudly matches that helper's documented posture.
*/
export async function assembleAttestationEnvelope(
attester: Attester,
binding: AttestationReportDataBinding,
runtimeClass: string,
): Promise<AttestationAssemblyResult> {
const reportData = buildAttestationReportData(binding);

let collection: AttestationCollection;
try {
collection = await attester.collect({ reportData, runtimeClass });
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
return { ok: false, errors: [`attester(${attester.kind}): collection failed: ${reason}`] };
}

const validation = validateAttestationEnvelope({
schemaVersion: 1,
teeTechnology: collection.teeTechnology,
runtimeClass,
measurement: collection.measurement,
reportData,
runId: binding.runId,
attestationReport: collection.attestationReport,
// Honest default: evidence captured, nothing has checked it yet. The verifier CLI (#9212) is what
// promotes this to verified/failed -- assembly never self-certifies.
verification: { status: "unverified" },
});

if (!validation.valid) return { ok: false, errors: validation.errors };
return { ok: true, envelope: validation.envelope };
}
1 change: 1 addition & 0 deletions packages/loopover-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export * from "./calibration/backtest-threshold.js";
export * from "./calibration/provider-track-record.js";
export * from "./calibration/reliability-curve.js";
export * from "./calibration/attestation-envelope.js";
export * from "./calibration/attester.js";
export {
GOVERNOR_LEDGER_EVENT_TYPES,
normalizeGovernorLedgerEvent,
Expand Down
41 changes: 41 additions & 0 deletions packages/loopover-engine/test/attester.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from "node:assert/strict";
import { test } from "node:test";

import {
SAMPLE_ATTESTATION_MAGIC,
assembleAttestationEnvelope,
buildAttestationReportData,
createSampleAttester,
isSampleAttestationReport,
validateAttestationEnvelope,
} from "../dist/index.js";

const BINDING = {
corpusChecksum: "a1".repeat(32), // 64 hex
headSha: "b2".repeat(20), // 40 hex
baseSha: "c3".repeat(20), // 40 hex
runId: "d4".repeat(16), // 32 hex
};
const RUNTIME_CLASS = "loopover-backtest-runner";

test("barrel: the public entrypoint re-exports the attester seam (#9211)", () => {
assert.equal(typeof createSampleAttester, "function");
assert.equal(typeof assembleAttestationEnvelope, "function");
assert.equal(typeof isSampleAttestationReport, "function");
assert.equal(typeof SAMPLE_ATTESTATION_MAGIC, "string");
});

test("the sample attester assembles an envelope the shipped validator accepts", async () => {
const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS);
assert.ok(result.ok);
assert.equal(validateAttestationEnvelope(result.envelope).valid, true);
// The commitment is recomputed from the binding, never restated by the attester.
assert.equal(result.envelope.reportData, buildAttestationReportData(BINDING));
assert.deepEqual(result.envelope.verification, { status: "unverified" });
});

test("a sample-attested report stays identifiable as a dev artifact, not evidence", async () => {
const result = await assembleAttestationEnvelope(createSampleAttester(), BINDING, RUNTIME_CLASS);
assert.ok(result.ok);
assert.equal(isSampleAttestationReport(result.envelope.attestationReport), true);
});
100 changes: 100 additions & 0 deletions scripts/attested-backtest-run-core.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Pure core for the attested backtest run (#9211, epic #8534). Decides WHETHER a run's attestation is
// acceptable and renders how it is persisted; the CLI (attested-backtest-run.ts) does the corpus read, the
// attester IO, and the D1 write. Mirrors backtest-logic-check-core.ts's pure-core / thin-IO split exactly,
// including its audit-event insert shape.
//
// The fail-closed rule lives here rather than in the CLI because it is the security-relevant decision in this
// feature: a runtime that CLAIMS a TEE must never be allowed to record a run as if attestation succeeded when
// it did not (#9211). Keeping it pure is what lets it be exhaustively tested without hardware.
import type { AttestationEnvelope } from "@loopover/engine";

import { sqlStringLiteral } from "./backtest-logic-check-core.js";

export const ATTESTED_BACKTEST_EVENT_TYPE = "calibration.attested_backtest_run";

/** What the runtime claims about itself, independent of what the attester actually returned. `none` is an
* ordinary un-attested dev run; `tee` asserts the workload believes it is inside a TEE runtime class. */
export type RuntimeAttestationClaim = "none" | "tee";

export type AttestedRunOutcome =
/** Evidence captured and structurally valid. `attesterKind` is recorded so a sample-attested run is never
* mistaken for hardware evidence downstream. */
| { status: "attested"; envelope: AttestationEnvelope; attesterKind: string }
/** The runtime claimed a TEE and evidence was NOT obtained -- the fail-closed case. */
| { status: "attestation_failed"; reason: string; attesterKind: string }
/** No TEE was claimed and none was obtained: an ordinary unattested run, not a failure. */
| { status: "unattested"; reason: string; attesterKind: string };

/**
* Decide a run's attestation outcome from what the runtime claimed and what the attester produced.
*
* The asymmetry is the point (#9211): under `claim: "tee"` a missing or invalid envelope is
* `attestation_failed` -- recorded, and non-zero-exiting at the CLI -- because a TEE runtime that cannot
* produce evidence is either misconfigured or lying, and silently degrading it to "unattested" is exactly the
* hole this epic exists to close. Under `claim: "none"` the same absence is simply `unattested`, so a
* developer running the pipeline on a laptop is not told their run failed.
*
* PURE. Never throws: an assembly failure arrives as `assembly.ok === false` and is classified, not raised.
*/
export function decideAttestedRunOutcome(input: {
claim: RuntimeAttestationClaim;
attesterKind: string;
assembly: { ok: true; envelope: AttestationEnvelope } | { ok: false; errors: string[] };
}): AttestedRunOutcome {
if (input.assembly.ok) {
return { status: "attested", envelope: input.assembly.envelope, attesterKind: input.attesterKind };
}
const reason = input.assembly.errors.join("; ");
if (input.claim === "tee") return { status: "attestation_failed", reason, attesterKind: input.attesterKind };
return { status: "unattested", reason, attesterKind: input.attesterKind };
}

/** Process exit code for an outcome: only the fail-closed case is non-zero, so an ordinary unattested dev run
* never breaks a pipeline while a TEE runtime that lost its evidence always does. */
export function attestedRunExitCode(outcome: AttestedRunOutcome): 0 | 1 {
return outcome.status === "attestation_failed" ? 1 : 0;
}

/**
* Render the audit-event INSERT for one attested run. Mirrors buildLogicBacktestAuditInsertSql's column set,
* actor, and `outcome: "completed"` convention -- "completed" means "this run recorded successfully"; the
* attestation verdict itself lives in `detail` and `metadata.attestation.status`, exactly as the sibling
* backtest writers put their verdict in metadata rather than in the fixed outcome enum.
*/
export function buildAttestedRunAuditInsertSql(input: {
id: string;
targetKey: string;
ruleId: string;
outcome: AttestedRunOutcome;
corpusChecksum: string;
headSha: string;
baseSha: string;
runId: string;
createdAt: string;
}): string {
const metadataJson = JSON.stringify({
attestation: {
status: input.outcome.status,
attesterKind: input.outcome.attesterKind,
...(input.outcome.status === "attested"
? { envelope: input.outcome.envelope }
: { reason: input.outcome.reason }),
},
ruleId: input.ruleId,
corpusChecksum: input.corpusChecksum,
headSha: input.headSha,
baseSha: input.baseSha,
runId: input.runId,
});
const values = [
sqlStringLiteral(input.id),
sqlStringLiteral(ATTESTED_BACKTEST_EVENT_TYPE),
"'loopover'",
sqlStringLiteral(input.targetKey),
"'completed'",
sqlStringLiteral(`attested backtest for ${input.ruleId}: ${input.outcome.status} (${input.outcome.attesterKind})`),
sqlStringLiteral(metadataJson),
sqlStringLiteral(input.createdAt),
].join(", ");
return `INSERT INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at) VALUES (${values})`;
}
Loading
Loading