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
27 changes: 19 additions & 8 deletions src/review/ledger-anchor-rekor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,18 @@ import type { SignedLedgerAnchor } from "./ledger-anchor";
import { anchorSigningInput } from "./ledger-anchor";
import { recordLedgerAnchorAttempt } from "./ledger-anchor-persistence";

/** Rekor shards annually and the research is explicit: do not hardcode a log URL. Configurable via env,
* with a fallback to the current shard as of when this was written -- an operator updates the env var at
* the next rotation rather than this needing a code change. */
const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2026-1.rekor.sigstore.dev";
/** Rekor shards annually as `log<year>-<rev>.rekor.sigstore.dev` and the research is explicit: do not
* hardcode a log URL. Configurable via env, so an operator updates a variable at the next rotation rather
* than waiting on a release.
*
* This default was previously `log2026-1`, a shard Sigstore has not deployed -- so it did not resolve, and
* EVERY deployment that enabled anchoring without setting the env var recorded `fetch failed` forever and
* published no anchor at all (#9844, found on a live self-host instance). Guessing the next shard ahead of
* its deployment is worse than lagging behind it: a stale-but-real default still anchors, while a
* not-yet-existent one silently anchors nothing.
*
* So: only ever point this at a shard confirmed to be serving. When 2026-1 goes live, this moves then. */
const DEFAULT_REKOR_SHARD_BASE_URL = "https://log2025-1.rekor.sigstore.dev";

/** The exact `hashedRekordRequestV002` body Rekor v2's `POST /api/v2/log/entries` accepts. Digest and
* signature are both base64 per the API; `keyDetails` names the algorithm so Rekor can verify without
Expand Down Expand Up @@ -155,16 +163,19 @@ export async function submitToRekor(
proofR2Key: null,
});
} catch (error) {
// Pass the raw caught value through, not a pre-stringified one -- #9271's persistence layer is the single
// place that normalizes an unknown error into text (Error instance vs. anything else), so this backend
// and every other one feed it the same undecided shape rather than each reimplementing that choice.
// The raw caught value still reaches the persistence layer, which stays the single place that normalizes
// an unknown error into text -- but it is WRAPPED so the recorded failure names the endpoint. Node's bare
// "fetch failed" cannot distinguish a shard hostname that does not resolve from blocked egress from a log
// that is down, and those have three different fixes. #9271 published these failures precisely so anyone
// can see anchoring is broken; a published failure that does not say what failed only half-delivers that.
// `cause` is preserved, so nothing a caller could previously inspect is lost.
await recordLedgerAnchorAttempt(env, {
payload: signed.payload,
signature: signed.signature,
keyId: signed.keyId,
backend: "rekor",
status: "failed",
error,
error: new Error(`Rekor submission to ${shardBaseUrl}/api/v2/log/entries failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error }),
});
}
}
42 changes: 39 additions & 3 deletions test/unit/ledger-anchor-rekor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,44 @@ describe("submitToRekor (#9272)", () => {
);
});

it("uses the default shard URL when unconfigured", async () => {
it("uses the default shard URL when unconfigured -- pinned EXACTLY, because a shard that does not exist still matches a substring", async () => {
// #9844: this assertion used to be `stringContaining("rekor.sigstore.dev")`, which passed happily while
// the default pointed at log2026-1 -- a shard Sigstore never deployed. Every deployment that enabled
// anchoring without overriding the env var recorded `fetch failed` forever and published no anchor, and
// this test could not have caught it. The exact host is the thing under test, so it is asserted exactly.
const env = createTestEnv();
const { signed, publicKeySpki } = await realSignedAnchor();
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify(REKOR_RESPONSE), { status: 201 }));
await submitToRekor(env, signed, publicKeySpki, fetchMock);
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("rekor.sigstore.dev"), expect.anything());
expect(fetchMock).toHaveBeenCalledWith("https://log2025-1.rekor.sigstore.dev/api/v2/log/entries", expect.anything());
});

it("REGRESSION (#9844): a thrown fetch error records WHICH endpoint failed, not a bare 'fetch failed'", async () => {
// Node's own message is "fetch failed" with no URL, so an operator cannot tell a shard hostname that does
// not resolve from blocked egress from a log that is down -- three different fixes. #9271 publishes these
// failures so anyone can see anchoring is broken; naming the endpoint is what makes that actionable.
const env = createTestEnv({ LOOPOVER_LEDGER_ANCHOR_REKOR_SHARD_URL: "https://log-does-not-exist.example" });
const { signed, publicKeySpki } = await realSignedAnchor();
const fetchMock = vi.fn().mockRejectedValue(new TypeError("fetch failed"));

await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined();

const { anchors } = await loadPublicLedgerAnchors(env);
expect(anchors[0]).toMatchObject({ backend: "rekor", status: "failed" });
expect(anchors[0]!.error).toContain("https://log-does-not-exist.example/api/v2/log/entries");
expect(anchors[0]!.error).toContain("fetch failed"); // the original cause is still legible
});

it("wraps a non-Error thrown value without losing it", async () => {
const env = createTestEnv();
const { signed, publicKeySpki } = await realSignedAnchor();
const fetchMock = vi.fn().mockRejectedValue("a bare string, not an Error");

await submitToRekor(env, signed, publicKeySpki, fetchMock);

const { anchors } = await loadPublicLedgerAnchors(env);
expect(anchors[0]!.error).toContain("a bare string, not an Error");
expect(anchors[0]!.error).toContain("log2025-1.rekor.sigstore.dev");
});

it("records status:'failed' on a non-2xx response, and does NOT throw", async () => {
Expand Down Expand Up @@ -124,6 +156,10 @@ describe("submitToRekor (#9272)", () => {
await expect(submitToRekor(env, signed, publicKeySpki, fetchMock)).resolves.toBeUndefined();

const { anchors } = await loadPublicLedgerAnchors(env);
expect(anchors[0]).toMatchObject({ status: "failed", error: "network down" });
// #9844: the cause is still legible, now alongside the endpoint that was attempted -- "network down" on
// its own could not tell an operator which of the backends or which URL had the problem.
expect(anchors[0]).toMatchObject({ status: "failed" });
expect(anchors[0]!.error).toContain("network down");
expect(anchors[0]!.error).toContain("/api/v2/log/entries");
});
});