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
2 changes: 1 addition & 1 deletion apps/loopover-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -20807,7 +20807,7 @@
"summary": "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor",
"responses": {
"200": {
"description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous"
"description": "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId, status, droppedEntries } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous. status (#9834) says WHICH: ok | unconfigured | malformed | no_valid_entries | expired | ambiguous_rotation, because all six previously rendered as an identical empty response that read as healthy. droppedEntries counts array entries rejected by validation, reported even when status is ok so one typo'd key among several is visible. The raw configured value is never echoed back under any status."
}
},
"operationId": "getPublicDecisionLedgerAnchorKey",
Expand Down
11 changes: 8 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ import { isRagEnabled } from "../review/rag-wire";
import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record";
import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records";
import { buildPublicCorpusCommitments } from "../review/public-eval-corpus";
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, diagnoseAnchorPublicKeys, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
import { resolveProofPage } from "../review/proof-summary";
import { renderProofBadgeSvg } from "./proof-badge";
import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor";
Expand Down Expand Up @@ -736,9 +736,14 @@ export function createApp() {
// verifiable yet" is the honest answer before the signing key is provisioned, and a verifier can tell that
// apart from a key that exists.
app.get("/v1/public/decision-ledger/anchor-key", (c) => {
const keys = parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS);
// #9834: `status`/`droppedEntries` alongside the keys, because `{"keys":[],"currentKeyId":null}` was the
// response to SIX different causes -- unset, unparseable, non-array, every-entry-invalid, all-expired,
// and an ambiguous rotation -- and read as a healthy empty state for all of them. Same reasoning
// PublicAnchorStatus already applies to the sibling anchors listing. `keys` and `currentKeyId` keep their
// exact shape and meaning; this is purely additive for existing consumers.
const diagnosis = diagnoseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS);
c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
return c.json({ keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null });
return c.json(diagnosis);
});

// #9271 (epic #9267): every anchoring attempt, success AND failure, paginated newest-first. This is what
Expand Down
2 changes: 1 addition & 1 deletion src/openapi/spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1906,7 +1906,7 @@ export function buildOpenApiSpec() {
tags: ["Public"],
summary: "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor",
responses: {
200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous" },
200: { description: "{ keys: [{ keyId, publicKeySpki, notBefore, notAfter }], currentKeyId, status, droppedEntries } — retired keys are retained so anchors signed under them stay verifiable; currentKeyId is null when unconfigured or the rotation state is ambiguous. status (#9834) says WHICH: ok | unconfigured | malformed | no_valid_entries | expired | ambiguous_rotation, because all six previously rendered as an identical empty response that read as healthy. droppedEntries counts array entries rejected by validation, reported even when status is ok so one typo'd key among several is visible. The raw configured value is never echoed back under any status." },
},
});
registry.registerPath({
Expand Down
68 changes: 68 additions & 0 deletions src/review/ledger-anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,74 @@ export function parseAnchorPublicKeys(raw: string | undefined): AnchorPublicKey[
});
}

/**
* Why {@link parseAnchorPublicKeys} produced what it produced (#9834).
*
* `parseAnchorPublicKeys` returns `[]` from four separate paths -- absent value, unparseable JSON, a non-array,
* and a filter that drops every entry -- and {@link currentAnchorKey} folds two more together, returning null
* both when NO key is current and when more than one is. Six causes, one `{"keys":[],"currentKeyId":null}`,
* which reads as a healthy empty state.
*
* This is the same defect {@link PublicAnchorStatus} was introduced to fix for the sibling anchors listing,
* never applied here. It matters concretely: after #9719 provisioned the keypair, whether that provisioning
* took effect was undeterminable -- a typo in the secret is byte-identical to an unset secret, from outside
* AND for the operator.
*/
export type AnchorKeyStatus =
/** Exactly one key has `notAfter: null`. Anchoring can sign. */
| "ok"
/** The env var is absent or empty -- never provisioned. */
| "unconfigured"
/** Present but not parseable as a JSON array: a truncated paste, a quoting mistake, an object. */
| "malformed"
/** A JSON array whose every entry failed field validation -- e.g. `keyid` for `keyId`. Shape right, keys wrong. */
| "no_valid_entries"
/** Valid entries, but every one carries a `notAfter` -- the rotation ran off the end without a successor. */
| "expired"
/** More than one entry claims `notAfter: null`. currentAnchorKey fails closed here (picking one would
* attribute anchors to a key that did not sign them), so this is unsigned-but-configured, not ok. */
| "ambiguous_rotation";

export type AnchorKeyDiagnosis = {
status: AnchorKeyStatus;
keys: AnchorPublicKey[];
currentKeyId: string | null;
/** Entries present in the array but rejected by validation. Reported even when `status` is "ok", so one
* typo'd key among three is visible rather than silently dropped into a healthy-looking response. */
droppedEntries: number;
};

/**
* PURE. Classify the raw env value, alongside the keys {@link parseAnchorPublicKeys} would return.
*
* NEVER echoes the raw value, under any status. `LOOPOVER_LEDGER_ANCHOR_KEYS` is served by an
* unauthenticated endpoint, and an operator who mis-pastes `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` into it
* would have the private half published by the very diagnostic meant to help them. A classification is
* always safe to return; the input never is.
*/
export function diagnoseAnchorPublicKeys(raw: string | undefined): AnchorKeyDiagnosis {
const empty = { keys: [] as AnchorPublicKey[], currentKeyId: null, droppedEntries: 0 };
if (!raw) return { status: "unconfigured", ...empty };

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { status: "malformed", ...empty };
}
if (!Array.isArray(parsed)) return { status: "malformed", ...empty };

const keys = parseAnchorPublicKeys(raw);
const droppedEntries = parsed.length - keys.length;
// An empty array is "configured to publish no keys", which is operationally the same actionable state as
// never setting it -- and distinct from "entries were present but every one was rejected".
if (keys.length === 0) return { status: parsed.length === 0 ? "unconfigured" : "no_valid_entries", ...empty };

const current = keys.filter((key) => key.notAfter === null);
const status: AnchorKeyStatus = current.length === 1 ? "ok" : current.length === 0 ? "expired" : "ambiguous_rotation";
return { status, keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null, droppedEntries };
}

/** The key currently signing anchors: the one entry with `notAfter: null`. Returns null when zero or MORE
* THAN ONE qualify -- an ambiguous rotation state must fail closed rather than silently pick one, since
* picking wrong would attribute anchors to a key that did not sign them. */
Expand Down
48 changes: 44 additions & 4 deletions test/integration/public-anchor-key-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ describe("GET /v1/public/decision-ledger/anchor-key (#9270)", () => {
expect(body.currentKeyId).toBe(ACTIVE.keyId);
});

it("reports an empty list and a null currentKeyId when unconfigured — 'nothing is claimed verifiable yet', distinguishable from a key that exists", async () => {
it("reports unconfigured explicitly — not just an empty list, which six different causes also produce", async () => {
// #9834: this assertion used to be `toEqual({ keys: [], currentKeyId: null })`, and its title claimed the
// response was "distinguishable from a key that exists". The empty half was true; the distinguishable
// half was not -- an unset secret, malformed JSON, a non-array, and an all-typo'd list were byte-identical.
const env = createTestEnv();
const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ keys: [], currentKeyId: null });
expect(await response.json()).toEqual({ status: "unconfigured", keys: [], currentKeyId: null, droppedEntries: 0 });
});

it("fails closed to a null currentKeyId on an ambiguous rotation state rather than guessing", async () => {
Expand All @@ -42,11 +45,48 @@ describe("GET /v1/public/decision-ledger/anchor-key (#9270)", () => {
expect(body.currentKeyId).toBeNull(); // ...but which one signs is genuinely ambiguous, so we do not claim
});

it("answers 200 with an empty list (never a 500) on malformed config", async () => {
it("answers 200 (never a 500) on malformed config, and says it is MALFORMED rather than empty", async () => {
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: "{not valid json" });
const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ keys: [], currentKeyId: null });
// #9834: "I cannot parse what you configured" and "you configured nothing" are different operator
// problems; serving one response for both is what made #9719's provisioning unverifiable.
expect(await response.json()).toEqual({ status: "malformed", keys: [], currentKeyId: null, droppedEntries: 0 });
});

// #9834: the remaining causes, each previously indistinguishable from "unconfigured".
it("distinguishes an all-typo'd list from an unset one", async () => {
const typod = { keyid: ACTIVE.keyId, publicKeySpki: ACTIVE.publicKeySpki, notBefore: ACTIVE.notBefore, notAfter: null };
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([typod]) });
const body = await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json();
expect(body).toMatchObject({ status: "no_valid_entries", keys: [], currentKeyId: null });
});

it("distinguishes an expired rotation from an ambiguous one -- both had a null currentKeyId", async () => {
const expired = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([RETIRED]) });
const ambiguous = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE, { ...ACTIVE, keyId: "second0000000000" }]) });
const app = createApp();

expect(await (await app.request("/v1/public/decision-ledger/anchor-key", {}, expired)).json()).toMatchObject({ status: "expired", currentKeyId: null });
expect(await (await app.request("/v1/public/decision-ledger/anchor-key", {}, ambiguous)).json()).toMatchObject({ status: "ambiguous_rotation", currentKeyId: null });
});

it("surfaces a dropped entry even when the deployment is otherwise healthy", async () => {
const typod = { keyid: "second0000000000", publicKeySpki: "eA==", notBefore: ACTIVE.notBefore, notAfter: null };
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE, typod]) });
const body = await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json();
expect(body).toMatchObject({ status: "ok", currentKeyId: ACTIVE.keyId, droppedEntries: 1 });
});

it("SECURITY: a private key mis-pasted into the PUBLIC var is never echoed back", async () => {
// The realistic operator error this diagnostic could have made catastrophic: the endpoint is
// unauthenticated, so echoing the configured value to explain a malformed status would publish the key.
const probe = ["-----BEGIN", " PRIVATE", " KEY-----", "MC4CAQAwBQYDK2VwBCIEIA", "-----END", " PRIVATE", " KEY-----"].join("");
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: probe });
const raw = JSON.stringify(await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json());
expect(raw).not.toContain("PRIVATE");
expect(raw).not.toContain("MC4CAQAwBQYDK2VwBCIEIA");
expect(raw).toContain("malformed");
});

it("never serves private key material, even if it is misconfigured into the public list", async () => {
Expand Down
84 changes: 84 additions & 0 deletions test/unit/ledger-anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildLedgerAnchorPayload,
computeAnchorKeyId,
currentAnchorKey,
diagnoseAnchorPublicKeys,
LEDGER_ANCHOR_LEDGER_ID,
LEDGER_ANCHOR_PAYLOAD_VERSION,
parseAnchorPublicKeys,
Expand Down Expand Up @@ -171,3 +172,86 @@ describe("currentAnchorKey / anchorKeyById (rotation)", () => {
expect(anchorKeyById([retired, active], "missing")).toBeNull();
});
});

// #9834: `{"keys":[],"currentKeyId":null}` was the answer to six different causes, and read as a healthy
// empty state for all of them. After #9719 provisioned the anchor keypair, that made "did the provisioning
// take effect?" unanswerable -- a typo in the secret is byte-identical to an unset secret.
describe("diagnoseAnchorPublicKeys (#9834)", () => {
const key = (over: Partial<AnchorPublicKey> = {}): AnchorPublicKey => ({
keyId: "k1",
publicKeySpki: "MCowBQYDK2VwAyEA" + "a".repeat(28),
notBefore: "2026-01-01T00:00:00.000Z",
notAfter: null,
...over,
});

it("ok: exactly one current key", () => {
const d = diagnoseAnchorPublicKeys(JSON.stringify([key()]));
expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 0 });
expect(d.keys).toHaveLength(1);
});

it("unconfigured: the env var is absent", () => {
expect(diagnoseAnchorPublicKeys(undefined)).toEqual({ status: "unconfigured", keys: [], currentKeyId: null, droppedEntries: 0 });
});

it("unconfigured: an empty string, and an explicitly empty array", () => {
// An empty array is "configured to publish nothing", which is the same actionable state as never setting
// it -- and deliberately NOT the same as entries present but every one rejected.
expect(diagnoseAnchorPublicKeys("").status).toBe("unconfigured");
expect(diagnoseAnchorPublicKeys("[]").status).toBe("unconfigured");
});

it("malformed: unparseable JSON", () => {
expect(diagnoseAnchorPublicKeys("{not json").status).toBe("malformed");
});

it("malformed: valid JSON that is not an array", () => {
expect(diagnoseAnchorPublicKeys(JSON.stringify(key())).status).toBe("malformed");
});

it("no_valid_entries: right shape, wrong field names -- the typo case", () => {
const typod = { keyid: "k1", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null };
const d = diagnoseAnchorPublicKeys(JSON.stringify([typod]));
expect(d).toMatchObject({ status: "no_valid_entries", keys: [], currentKeyId: null });
});

it("expired: valid entries, none current -- the rotation ran off the end", () => {
const d = diagnoseAnchorPublicKeys(JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })]));
expect(d).toMatchObject({ status: "expired", currentKeyId: null });
expect(d.keys).toHaveLength(1); // retired keys still published, so old anchors stay verifiable
});

it("ambiguous_rotation: MORE than one current key -- the other half of currentAnchorKey's null", () => {
// currentAnchorKey returns null for both zero and >1 current keys. Those are different operator problems
// (publish a successor vs. retire one of two), and this is the only place they are told apart.
const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), key({ keyId: "k2" })]));
expect(d).toMatchObject({ status: "ambiguous_rotation", currentKeyId: null, droppedEntries: 0 });
expect(d.keys).toHaveLength(2);
});

it("counts droppedEntries even when the overall status is ok", () => {
// One good key and one typo'd: without the count, the bad entry vanishes into a healthy-looking response.
const typod = { keyid: "k2", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null };
const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), typod]));
expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 1 });
});

it("SECURITY: never echoes the configured value back, whatever it is", () => {
// LOOPOVER_LEDGER_ANCHOR_KEYS is served by an UNAUTHENTICATED endpoint. An operator who mis-pastes
// LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY into it must not have the private half published by the very
// diagnostic meant to help them. Probe assembled from fragments so this file never contains a
// credential-shaped literal -- the convention forbidden-content.test.ts uses.
const probe = ["-----BEGIN", " PRIVATE", " KEY-----", "MC4CAQAwBQYDK2VwBCIEIA", "-----END", " PRIVATE", " KEY-----"].join("");
for (const raw of [probe, `[${probe}]`, JSON.stringify([{ keyId: probe }])]) {
expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("PRIVATE");
expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("MC4CAQAwBQYDK2VwBCIEIA");
}
});

it("INVARIANT: parseAnchorPublicKeys is unchanged -- the scheduler's guard order still sees what it always did", () => {
for (const raw of [undefined, "", "[]", "{not json", JSON.stringify([key()]), JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })])]) {
expect(diagnoseAnchorPublicKeys(raw).keys).toEqual(parseAnchorPublicKeys(raw));
}
});
});
Loading