Skip to content

Commit 2b2c98a

Browse files
authored
fix(ledger): say WHY the anchor-key list is empty, not just that it is (#9835)
`GET /v1/public/decision-ledger/anchor-key` answered `{"keys":[],"currentKeyId":null}` to six different causes: the secret unset, unparseable JSON, a non-array, every entry failing validation, all keys expired, and an ambiguous rotation. parseAnchorPublicKeys returns [] from four separate paths with no diagnostic, and currentAnchorKey folds two more together by returning null both when NO key is current and when more than one is. The consequence is concrete rather than cosmetic. #9719 provisioned the anchor keypair and was closed, but whether that took effect on the hosted deployment could not be determined -- from outside OR by the operator -- because a typo in the secret is byte-identical to an unset secret. An empty response reads as a healthy empty state. This is the same defect PublicAnchorStatus was introduced to fix for the sibling anchors listing ("never configured", "no ledger to anchor" and "healthy but has not run yet" all rendering as {"anchors":[]}), never applied here. It could not be answered by that status either: publicAnchorStatus checks tipSeq === 0 before hasSigningKey, so on an empty ledger it reports empty_ledger whether or not a key is configured -- deliberate, since it mirrors the scheduler's own guard order, and left unchanged. diagnoseAnchorPublicKeys is additive and pure. parseAnchorPublicKeys keeps its exact signature and behaviour (the scheduler depends on it, asserted), and `keys`/`currentKeyId` keep their shape for existing consumers. droppedEntries is reported even when the status is ok, so one typo'd key among several is visible instead of silently discarded into a healthy-looking response. The raw value is NEVER echoed back under any status. This endpoint is unauthenticated, and an operator who mis-pastes LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY into the public var would otherwise have the private half published by the very diagnostic meant to help them. Closes #9834
1 parent 8659503 commit 2b2c98a

6 files changed

Lines changed: 206 additions & 9 deletions

File tree

apps/loopover-ui/public/openapi.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20807,7 +20807,7 @@
2080720807
"summary": "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor",
2080820808
"responses": {
2080920809
"200": {
20810-
"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"
20810+
"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."
2081120811
}
2081220812
},
2081320813
"operationId": "getPublicDecisionLedgerAnchorKey",

src/api/routes.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ import { isRagEnabled } from "../review/rag-wire";
305305
import { loadDecisionLedgerTip, loadPublicDecisionRecord, loadPublicLedgerRow, verifyDecisionLedger } from "../review/decision-record";
306306
import { buildEvalScoreRecordsFromRulePrecision, filterEvalScoreRecords } from "../review/eval-score-records";
307307
import { buildPublicCorpusCommitments } from "../review/public-eval-corpus";
308-
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
308+
import { anchorSigningInput, buildLedgerAnchorPayload, currentAnchorKey, diagnoseAnchorPublicKeys, parseAnchorPublicKeys, publicAnchorStatus, signLedgerAnchorPayload } from "../review/ledger-anchor";
309309
import { resolveProofPage } from "../review/proof-summary";
310310
import { renderProofBadgeSvg } from "./proof-badge";
311311
import { ingestBittensorAnchorReport, parseBittensorAnchorReport } from "../review/ledger-anchor-bittensor";
@@ -736,9 +736,14 @@ export function createApp() {
736736
// verifiable yet" is the honest answer before the signing key is provisioned, and a verifier can tell that
737737
// apart from a key that exists.
738738
app.get("/v1/public/decision-ledger/anchor-key", (c) => {
739-
const keys = parseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS);
739+
// #9834: `status`/`droppedEntries` alongside the keys, because `{"keys":[],"currentKeyId":null}` was the
740+
// response to SIX different causes -- unset, unparseable, non-array, every-entry-invalid, all-expired,
741+
// and an ambiguous rotation -- and read as a healthy empty state for all of them. Same reasoning
742+
// PublicAnchorStatus already applies to the sibling anchors listing. `keys` and `currentKeyId` keep their
743+
// exact shape and meaning; this is purely additive for existing consumers.
744+
const diagnosis = diagnoseAnchorPublicKeys(c.env.LOOPOVER_LEDGER_ANCHOR_KEYS);
740745
c.header("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
741-
return c.json({ keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null });
746+
return c.json(diagnosis);
742747
});
743748

744749
// #9271 (epic #9267): every anchoring attempt, success AND failure, paginated newest-first. This is what

src/openapi/spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1906,7 +1906,7 @@ export function buildOpenApiSpec() {
19061906
tags: ["Public"],
19071907
summary: "Published anchor-signing public keys with their full rotation history, for verifying an externally-published ledger anchor",
19081908
responses: {
1909-
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" },
1909+
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." },
19101910
},
19111911
});
19121912
registry.registerPath({

src/review/ledger-anchor.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,74 @@ export function parseAnchorPublicKeys(raw: string | undefined): AnchorPublicKey[
197197
});
198198
}
199199

200+
/**
201+
* Why {@link parseAnchorPublicKeys} produced what it produced (#9834).
202+
*
203+
* `parseAnchorPublicKeys` returns `[]` from four separate paths -- absent value, unparseable JSON, a non-array,
204+
* and a filter that drops every entry -- and {@link currentAnchorKey} folds two more together, returning null
205+
* both when NO key is current and when more than one is. Six causes, one `{"keys":[],"currentKeyId":null}`,
206+
* which reads as a healthy empty state.
207+
*
208+
* This is the same defect {@link PublicAnchorStatus} was introduced to fix for the sibling anchors listing,
209+
* never applied here. It matters concretely: after #9719 provisioned the keypair, whether that provisioning
210+
* took effect was undeterminable -- a typo in the secret is byte-identical to an unset secret, from outside
211+
* AND for the operator.
212+
*/
213+
export type AnchorKeyStatus =
214+
/** Exactly one key has `notAfter: null`. Anchoring can sign. */
215+
| "ok"
216+
/** The env var is absent or empty -- never provisioned. */
217+
| "unconfigured"
218+
/** Present but not parseable as a JSON array: a truncated paste, a quoting mistake, an object. */
219+
| "malformed"
220+
/** A JSON array whose every entry failed field validation -- e.g. `keyid` for `keyId`. Shape right, keys wrong. */
221+
| "no_valid_entries"
222+
/** Valid entries, but every one carries a `notAfter` -- the rotation ran off the end without a successor. */
223+
| "expired"
224+
/** More than one entry claims `notAfter: null`. currentAnchorKey fails closed here (picking one would
225+
* attribute anchors to a key that did not sign them), so this is unsigned-but-configured, not ok. */
226+
| "ambiguous_rotation";
227+
228+
export type AnchorKeyDiagnosis = {
229+
status: AnchorKeyStatus;
230+
keys: AnchorPublicKey[];
231+
currentKeyId: string | null;
232+
/** Entries present in the array but rejected by validation. Reported even when `status` is "ok", so one
233+
* typo'd key among three is visible rather than silently dropped into a healthy-looking response. */
234+
droppedEntries: number;
235+
};
236+
237+
/**
238+
* PURE. Classify the raw env value, alongside the keys {@link parseAnchorPublicKeys} would return.
239+
*
240+
* NEVER echoes the raw value, under any status. `LOOPOVER_LEDGER_ANCHOR_KEYS` is served by an
241+
* unauthenticated endpoint, and an operator who mis-pastes `LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY` into it
242+
* would have the private half published by the very diagnostic meant to help them. A classification is
243+
* always safe to return; the input never is.
244+
*/
245+
export function diagnoseAnchorPublicKeys(raw: string | undefined): AnchorKeyDiagnosis {
246+
const empty = { keys: [] as AnchorPublicKey[], currentKeyId: null, droppedEntries: 0 };
247+
if (!raw) return { status: "unconfigured", ...empty };
248+
249+
let parsed: unknown;
250+
try {
251+
parsed = JSON.parse(raw);
252+
} catch {
253+
return { status: "malformed", ...empty };
254+
}
255+
if (!Array.isArray(parsed)) return { status: "malformed", ...empty };
256+
257+
const keys = parseAnchorPublicKeys(raw);
258+
const droppedEntries = parsed.length - keys.length;
259+
// An empty array is "configured to publish no keys", which is operationally the same actionable state as
260+
// never setting it -- and distinct from "entries were present but every one was rejected".
261+
if (keys.length === 0) return { status: parsed.length === 0 ? "unconfigured" : "no_valid_entries", ...empty };
262+
263+
const current = keys.filter((key) => key.notAfter === null);
264+
const status: AnchorKeyStatus = current.length === 1 ? "ok" : current.length === 0 ? "expired" : "ambiguous_rotation";
265+
return { status, keys, currentKeyId: currentAnchorKey(keys)?.keyId ?? null, droppedEntries };
266+
}
267+
200268
/** The key currently signing anchors: the one entry with `notAfter: null`. Returns null when zero or MORE
201269
* THAN ONE qualify -- an ambiguous rotation state must fail closed rather than silently pick one, since
202270
* picking wrong would attribute anchors to a key that did not sign them. */

test/integration/public-anchor-key-route.test.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,14 @@ describe("GET /v1/public/decision-ledger/anchor-key (#9270)", () => {
2626
expect(body.currentKeyId).toBe(ACTIVE.keyId);
2727
});
2828

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

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

45-
it("answers 200 with an empty list (never a 500) on malformed config", async () => {
48+
it("answers 200 (never a 500) on malformed config, and says it is MALFORMED rather than empty", async () => {
4649
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: "{not valid json" });
4750
const response = await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env);
4851
expect(response.status).toBe(200);
49-
expect(await response.json()).toEqual({ keys: [], currentKeyId: null });
52+
// #9834: "I cannot parse what you configured" and "you configured nothing" are different operator
53+
// problems; serving one response for both is what made #9719's provisioning unverifiable.
54+
expect(await response.json()).toEqual({ status: "malformed", keys: [], currentKeyId: null, droppedEntries: 0 });
55+
});
56+
57+
// #9834: the remaining causes, each previously indistinguishable from "unconfigured".
58+
it("distinguishes an all-typo'd list from an unset one", async () => {
59+
const typod = { keyid: ACTIVE.keyId, publicKeySpki: ACTIVE.publicKeySpki, notBefore: ACTIVE.notBefore, notAfter: null };
60+
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([typod]) });
61+
const body = await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json();
62+
expect(body).toMatchObject({ status: "no_valid_entries", keys: [], currentKeyId: null });
63+
});
64+
65+
it("distinguishes an expired rotation from an ambiguous one -- both had a null currentKeyId", async () => {
66+
const expired = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([RETIRED]) });
67+
const ambiguous = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE, { ...ACTIVE, keyId: "second0000000000" }]) });
68+
const app = createApp();
69+
70+
expect(await (await app.request("/v1/public/decision-ledger/anchor-key", {}, expired)).json()).toMatchObject({ status: "expired", currentKeyId: null });
71+
expect(await (await app.request("/v1/public/decision-ledger/anchor-key", {}, ambiguous)).json()).toMatchObject({ status: "ambiguous_rotation", currentKeyId: null });
72+
});
73+
74+
it("surfaces a dropped entry even when the deployment is otherwise healthy", async () => {
75+
const typod = { keyid: "second0000000000", publicKeySpki: "eA==", notBefore: ACTIVE.notBefore, notAfter: null };
76+
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: JSON.stringify([ACTIVE, typod]) });
77+
const body = await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json();
78+
expect(body).toMatchObject({ status: "ok", currentKeyId: ACTIVE.keyId, droppedEntries: 1 });
79+
});
80+
81+
it("SECURITY: a private key mis-pasted into the PUBLIC var is never echoed back", async () => {
82+
// The realistic operator error this diagnostic could have made catastrophic: the endpoint is
83+
// unauthenticated, so echoing the configured value to explain a malformed status would publish the key.
84+
const probe = ["-----BEGIN", " PRIVATE", " KEY-----", "MC4CAQAwBQYDK2VwBCIEIA", "-----END", " PRIVATE", " KEY-----"].join("");
85+
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_KEYS: probe });
86+
const raw = JSON.stringify(await (await createApp().request("/v1/public/decision-ledger/anchor-key", {}, env)).json());
87+
expect(raw).not.toContain("PRIVATE");
88+
expect(raw).not.toContain("MC4CAQAwBQYDK2VwBCIEIA");
89+
expect(raw).toContain("malformed");
5090
});
5191

5292
it("never serves private key material, even if it is misconfigured into the public list", async () => {

test/unit/ledger-anchor.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
buildLedgerAnchorPayload,
66
computeAnchorKeyId,
77
currentAnchorKey,
8+
diagnoseAnchorPublicKeys,
89
LEDGER_ANCHOR_LEDGER_ID,
910
LEDGER_ANCHOR_PAYLOAD_VERSION,
1011
parseAnchorPublicKeys,
@@ -171,3 +172,86 @@ describe("currentAnchorKey / anchorKeyById (rotation)", () => {
171172
expect(anchorKeyById([retired, active], "missing")).toBeNull();
172173
});
173174
});
175+
176+
// #9834: `{"keys":[],"currentKeyId":null}` was the answer to six different causes, and read as a healthy
177+
// empty state for all of them. After #9719 provisioned the anchor keypair, that made "did the provisioning
178+
// take effect?" unanswerable -- a typo in the secret is byte-identical to an unset secret.
179+
describe("diagnoseAnchorPublicKeys (#9834)", () => {
180+
const key = (over: Partial<AnchorPublicKey> = {}): AnchorPublicKey => ({
181+
keyId: "k1",
182+
publicKeySpki: "MCowBQYDK2VwAyEA" + "a".repeat(28),
183+
notBefore: "2026-01-01T00:00:00.000Z",
184+
notAfter: null,
185+
...over,
186+
});
187+
188+
it("ok: exactly one current key", () => {
189+
const d = diagnoseAnchorPublicKeys(JSON.stringify([key()]));
190+
expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 0 });
191+
expect(d.keys).toHaveLength(1);
192+
});
193+
194+
it("unconfigured: the env var is absent", () => {
195+
expect(diagnoseAnchorPublicKeys(undefined)).toEqual({ status: "unconfigured", keys: [], currentKeyId: null, droppedEntries: 0 });
196+
});
197+
198+
it("unconfigured: an empty string, and an explicitly empty array", () => {
199+
// An empty array is "configured to publish nothing", which is the same actionable state as never setting
200+
// it -- and deliberately NOT the same as entries present but every one rejected.
201+
expect(diagnoseAnchorPublicKeys("").status).toBe("unconfigured");
202+
expect(diagnoseAnchorPublicKeys("[]").status).toBe("unconfigured");
203+
});
204+
205+
it("malformed: unparseable JSON", () => {
206+
expect(diagnoseAnchorPublicKeys("{not json").status).toBe("malformed");
207+
});
208+
209+
it("malformed: valid JSON that is not an array", () => {
210+
expect(diagnoseAnchorPublicKeys(JSON.stringify(key())).status).toBe("malformed");
211+
});
212+
213+
it("no_valid_entries: right shape, wrong field names -- the typo case", () => {
214+
const typod = { keyid: "k1", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null };
215+
const d = diagnoseAnchorPublicKeys(JSON.stringify([typod]));
216+
expect(d).toMatchObject({ status: "no_valid_entries", keys: [], currentKeyId: null });
217+
});
218+
219+
it("expired: valid entries, none current -- the rotation ran off the end", () => {
220+
const d = diagnoseAnchorPublicKeys(JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })]));
221+
expect(d).toMatchObject({ status: "expired", currentKeyId: null });
222+
expect(d.keys).toHaveLength(1); // retired keys still published, so old anchors stay verifiable
223+
});
224+
225+
it("ambiguous_rotation: MORE than one current key -- the other half of currentAnchorKey's null", () => {
226+
// currentAnchorKey returns null for both zero and >1 current keys. Those are different operator problems
227+
// (publish a successor vs. retire one of two), and this is the only place they are told apart.
228+
const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), key({ keyId: "k2" })]));
229+
expect(d).toMatchObject({ status: "ambiguous_rotation", currentKeyId: null, droppedEntries: 0 });
230+
expect(d.keys).toHaveLength(2);
231+
});
232+
233+
it("counts droppedEntries even when the overall status is ok", () => {
234+
// One good key and one typo'd: without the count, the bad entry vanishes into a healthy-looking response.
235+
const typod = { keyid: "k2", publicKeySpki: "x", notBefore: "2026-01-01T00:00:00.000Z", notAfter: null };
236+
const d = diagnoseAnchorPublicKeys(JSON.stringify([key(), typod]));
237+
expect(d).toMatchObject({ status: "ok", currentKeyId: "k1", droppedEntries: 1 });
238+
});
239+
240+
it("SECURITY: never echoes the configured value back, whatever it is", () => {
241+
// LOOPOVER_LEDGER_ANCHOR_KEYS is served by an UNAUTHENTICATED endpoint. An operator who mis-pastes
242+
// LOOPOVER_LEDGER_ANCHOR_PRIVATE_KEY into it must not have the private half published by the very
243+
// diagnostic meant to help them. Probe assembled from fragments so this file never contains a
244+
// credential-shaped literal -- the convention forbidden-content.test.ts uses.
245+
const probe = ["-----BEGIN", " PRIVATE", " KEY-----", "MC4CAQAwBQYDK2VwBCIEIA", "-----END", " PRIVATE", " KEY-----"].join("");
246+
for (const raw of [probe, `[${probe}]`, JSON.stringify([{ keyId: probe }])]) {
247+
expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("PRIVATE");
248+
expect(JSON.stringify(diagnoseAnchorPublicKeys(raw))).not.toContain("MC4CAQAwBQYDK2VwBCIEIA");
249+
}
250+
});
251+
252+
it("INVARIANT: parseAnchorPublicKeys is unchanged -- the scheduler's guard order still sees what it always did", () => {
253+
for (const raw of [undefined, "", "[]", "{not json", JSON.stringify([key()]), JSON.stringify([key({ notAfter: "2026-06-01T00:00:00.000Z" })])]) {
254+
expect(diagnoseAnchorPublicKeys(raw).keys).toEqual(parseAnchorPublicKeys(raw));
255+
}
256+
});
257+
});

0 commit comments

Comments
 (0)