Skip to content

Commit 512f049

Browse files
committed
fix: answer review on ledger integrity and JWKS rotation
Bind every ledger column into the chain hash. The preimage covered only payloadJson and prevHash, so entryType, jws, createdAt and the identifiers were rewritable with every hash still verifying — an attestation.late entry could be relabelled issued, or a signature swapped between entries. Refuse to nest a storage transaction. SQLite has no nested transactions, so a second BEGIN IMMEDIATE threw and the surrounding catch rolled back the outer transaction. One helper now tracks the open transaction per connection and fails explicitly instead. Re-resolve discovery on the forced JWKS refresh. A provider that rotates its signing key and its jwks_uri together published the new key only at the new URI, so the forced refresh refetched the retired one and every sponsor proof failed until the discovery cache expired. The path stays behind the per-issuer cooldown, so an unknown kid still cannot amplify. Import the signing key once per finalize request instead of once per commit. Verification: build, typecheck, test (480 pass, 0 fail) and the SDK contract check all exited 0. The two new OIDC tests were confirmed red before the fix.
1 parent 528b8d9 commit 512f049

5 files changed

Lines changed: 326 additions & 83 deletions

File tree

packages/server/src/__tests__/attestations.test.ts

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,51 @@ type FinalizeResponse = {
2525
attestations: Array<{ sha: string; jws: string }>;
2626
};
2727

28+
type LedgerRow = {
29+
seq: number;
30+
org_id: string;
31+
org_seq: number;
32+
entry_type: string;
33+
jti: string | null;
34+
commit_sha: string | null;
35+
repo: string | null;
36+
agent_id: string | null;
37+
sponsor_id: string | null;
38+
payload_json: string;
39+
jws: string;
40+
prev_hash: string;
41+
entry_hash: string;
42+
created_at: string;
43+
};
44+
45+
/**
46+
* Recompute an entry's chain hash the way an external auditor would: from the
47+
* stored columns alone, spelling out the canonical preimage rather than calling
48+
* the production helper. If the preimage ever changes shape, this fails.
49+
*/
50+
function recomputeEntryHash(row: LedgerRow): string {
51+
const preimage: Record<string, string | number | null> = {
52+
agentId: row.agent_id,
53+
commitSha: row.commit_sha,
54+
createdAt: row.created_at,
55+
entryType: row.entry_type,
56+
jti: row.jti,
57+
jws: row.jws,
58+
orgId: row.org_id,
59+
orgSeq: row.org_seq,
60+
payloadJson: row.payload_json,
61+
prevHash: row.prev_hash,
62+
repo: row.repo,
63+
sponsorId: row.sponsor_id,
64+
};
65+
const canonical = `{${
66+
Object.keys(preimage).sort()
67+
.map((key) => `${JSON.stringify(key)}:${JSON.stringify(preimage[key])}`)
68+
.join(",")
69+
}}`;
70+
return crypto.createHash("sha256").update(canonical, "utf8").digest("hex");
71+
}
72+
2873
function createIdentity(): StoredIdentity {
2974
const base = generateTestIdentity({
3075
id: "agent_attestation",
@@ -277,9 +322,11 @@ test("ledger is immutable and retention only deletes audit_logs", async (t) => {
277322
jws: "test-jws-retention",
278323
createdAt: "2000-01-01T00:00:00.000Z",
279324
});
280-
const row = await app.storage.DB.prepare(
281-
"SELECT seq, payload_json, prev_hash, entry_hash FROM attestation_ledger LIMIT 1",
282-
).first<{ seq: number; payload_json: string; prev_hash: string; entry_hash: string }>();
325+
const row = await app.storage.DB.prepare(`
326+
SELECT seq, org_id, org_seq, entry_type, jti, commit_sha, repo, agent_id, sponsor_id,
327+
payload_json, jws, prev_hash, entry_hash, created_at
328+
FROM attestation_ledger LIMIT 1
329+
`).first<LedgerRow>();
283330
assert.ok(row);
284331
await assert.rejects(
285332
app.storage.DB.prepare("UPDATE attestation_ledger SET payload_json = ? WHERE seq = ?")
@@ -289,16 +336,29 @@ test("ledger is immutable and retention only deletes audit_logs", async (t) => {
289336
app.storage.DB.prepare("DELETE FROM attestation_ledger WHERE seq = ?")
290337
.bind(row.seq).run(),
291338
);
292-
const recomputed = crypto.createHash("sha256")
293-
.update(row.payload_json, "utf8")
294-
.update(row.prev_hash, "utf8")
295-
.digest("hex");
296-
assert.equal(recomputed, row.entry_hash);
297-
const handTampered = crypto.createHash("sha256")
298-
.update('{"tampered":true}', "utf8")
299-
.update(row.prev_hash, "utf8")
300-
.digest("hex");
301-
assert.notEqual(handTampered, row.entry_hash, "chain recomputation must detect a hand-tampered payload");
339+
assert.equal(recomputeEntryHash(row), row.entry_hash);
340+
// Every field a verifier reads must be inside the preimage, so tampering with
341+
// any one of them has to break the chain — not just the payload.
342+
assert.notEqual(
343+
recomputeEntryHash({ ...row, payload_json: '{"tampered":true}' }),
344+
row.entry_hash,
345+
"chain recomputation must detect a hand-tampered payload",
346+
);
347+
assert.notEqual(
348+
recomputeEntryHash({ ...row, entry_type: "attestation.issued" }),
349+
row.entry_hash,
350+
"chain recomputation must detect a relabelled entry_type",
351+
);
352+
assert.notEqual(
353+
recomputeEntryHash({ ...row, jws: "swapped-signature" }),
354+
row.entry_hash,
355+
"chain recomputation must detect a swapped jws",
356+
);
357+
assert.notEqual(
358+
recomputeEntryHash({ ...row, created_at: "1999-01-01T00:00:00.000Z" }),
359+
row.entry_hash,
360+
"chain recomputation must detect a rewritten created_at",
361+
);
302362

303363
await app.storage.DB.prepare(`
304364
INSERT INTO audit_logs (id, action, org_id, result, timestamp, created_at)

packages/server/src/__tests__/sponsor-oidc-binding.test.ts

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616

1717
const OIDC_KEY_PAIR = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
1818
const OIDC_KID = "fixture-rs256-key";
19+
const ROTATED_OIDC_KID = "fixture-rs256-key-rotated";
1920
const OIDC_PUBLIC_JWK = {
2021
...(OIDC_KEY_PAIR.publicKey.export({ format: "jwk" }) as JsonWebKey),
2122
alg: "RS256",
@@ -80,6 +81,57 @@ async function startOidcFixture(
8081
};
8182
}
8283

84+
/**
85+
* An IdP that rotates its signing key and its `jwks_uri` in the same step.
86+
*
87+
* This is the case a cached discovery document hides: the new key is only
88+
* published at the new URI, so a client that refreshes the JWKS without
89+
* re-resolving discovery refetches the retired URI and never finds it.
90+
*/
91+
async function startRotatingOidcFixture(
92+
t: test.TestContext,
93+
cacheControl = "public, max-age=0",
94+
): Promise<{ issuer: string; rotate(): void; requestCount(path: string): number }> {
95+
let issuer = "";
96+
let jwksPath = "/jwks-initial";
97+
let servedKid = OIDC_KID;
98+
const requestCounts = new Map<string, number>();
99+
const server = createServer((request, response) => {
100+
const path = request.url ?? "";
101+
requestCounts.set(path, (requestCounts.get(path) ?? 0) + 1);
102+
response.setHeader("content-type", "application/json");
103+
response.setHeader("cache-control", cacheControl);
104+
if (path === "/.well-known/openid-configuration") {
105+
response.end(JSON.stringify({ issuer, jwks_uri: `${issuer}${jwksPath}` }));
106+
return;
107+
}
108+
if (path === jwksPath) {
109+
response.end(JSON.stringify({ keys: [{ ...OIDC_PUBLIC_JWK, kid: servedKid }] }));
110+
return;
111+
}
112+
response.statusCode = 404;
113+
response.end(JSON.stringify({ error: "not_found" }));
114+
});
115+
116+
await new Promise<void>((resolve, reject) => {
117+
server.once("error", reject);
118+
server.listen(0, "127.0.0.1", resolve);
119+
});
120+
const address = server.address() as AddressInfo;
121+
issuer = `http://127.0.0.1:${address.port}`;
122+
t.after(() => new Promise<void>((resolve, reject) => {
123+
server.close((error) => error ? reject(error) : resolve());
124+
}));
125+
return {
126+
issuer,
127+
rotate: () => {
128+
jwksPath = "/jwks-rotated";
129+
servedKid = ROTATED_OIDC_KID;
130+
},
131+
requestCount: (path: string) => requestCounts.get(path) ?? 0,
132+
};
133+
}
134+
83135
function adminAuthorization(org: string): HeadersInit {
84136
return {
85137
Authorization: `Bearer ${generateTestToken({
@@ -485,12 +537,57 @@ test("unknown OIDC kids cannot bypass JWKS cache or forced-refresh cooldown", as
485537
);
486538

487539
await assertJsonResponse<SponsorProof>(await proofRequest(OIDC_KID), 201);
488-
await assertJsonResponse<{ code: string }>(await proofRequest("attacker-kid-1"), 403);
489-
await assertJsonResponse<{ code: string }>(await proofRequest("attacker-kid-2"), 403);
490-
assert.equal(requestCount("/.well-known/openid-configuration"), 1);
540+
for (let attempt = 1; attempt <= 5; attempt++) {
541+
await assertJsonResponse<{ code: string }>(await proofRequest(`attacker-kid-${attempt}`), 403);
542+
}
543+
// The cooldown admits one forced refresh per issuer however many unknown
544+
// kids arrive, and max-age=0 cannot drive a fetch per request because the
545+
// parsed cache lifetime has a floor. That single refresh re-resolves the
546+
// discovery document as well as the JWKS, so the ceiling is one extra fetch
547+
// of each per window rather than one of each per request.
548+
assert.equal(requestCount("/.well-known/openid-configuration"), 2);
491549
assert.equal(requestCount("/jwks"), 2);
492550
});
493551

552+
test("a provider that rotates its signing key and jwks_uri together stays reachable", async (t) => {
553+
const { issuer, rotate } = await startRotatingOidcFixture(t);
554+
const org = "org_oidc_jwks_uri_rotation";
555+
const app = createTestApp({
556+
RELAYAUTH_SPONSOR_FEDERATIONS: JSON.stringify({
557+
[org]: { sponsorBinding: "oidc", issuer, clientId: "chief-fixture" },
558+
}),
559+
});
560+
const apiKey = await createWorkspaceApiKey(app, org);
561+
const now = Math.floor(Date.now() / 1000);
562+
const proofRequest = (kid: string) => app.request(
563+
createTestRequest(
564+
"POST",
565+
"/v1/sponsors/proof",
566+
{
567+
idToken: signIdToken({
568+
iss: issuer,
569+
sub: "alice",
570+
aud: "chief-fixture",
571+
iat: now,
572+
exp: now + 300,
573+
}, kid),
574+
intent: "approval",
575+
},
576+
{ "x-api-key": apiKey },
577+
),
578+
undefined,
579+
app.bindings,
580+
);
581+
582+
await assertJsonResponse<SponsorProof>(await proofRequest(OIDC_KID), 201);
583+
rotate();
584+
// The unknown kid drives the one permitted forced refresh. That refresh has
585+
// to re-resolve discovery too: resolving only the JWKS would refetch the
586+
// retired jwks_uri, so the rotated key would stay unreachable and every
587+
// sponsor proof for this org would fail until the discovery cache expired.
588+
await assertJsonResponse<SponsorProof>(await proofRequest(ROTATED_OIDC_KID), 201);
589+
});
590+
494591
test("OIDC subject mapping is collision-free for raw and encoded-looking values", async (t) => {
495592
const { issuer } = await startOidcFixture(t);
496593
const org = "org_oidc_subject_encoding";

packages/server/src/lib/sponsor-binding.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,9 +413,12 @@ export class SponsorOidcService {
413413
return validateJwks(config.jwks);
414414
}
415415

416-
// Key rotation refreshes the JWKS itself; the separately cached discovery
417-
// document must not be refetched for every attacker-controlled unknown kid.
418-
const jwksUri = config.jwksUri ?? await this.#resolveJwksUri(config, false);
416+
// A provider can rotate its signing keys and its jwks_uri together, so the
417+
// forced refresh must be able to re-resolve discovery too — otherwise the
418+
// rotated key is fetched from the stale URI and never found. This path is
419+
// reachable only behind #canForceRefresh, which caps an attacker-controlled
420+
// unknown kid at one discovery + one JWKS fetch per issuer per cooldown.
421+
const jwksUri = config.jwksUri ?? await this.#resolveJwksUri(config, forceRefresh);
419422
assertSecureProviderUrl(jwksUri, "jwksUri");
420423
const cached = this.#jwksCache.get(jwksUri);
421424
if (!forceRefresh && cached && cached.expiresAt > Date.now()) {

packages/server/src/routes/attestations.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { matchScope } from "@relayauth/sdk";
55
import type { AppEnv } from "../env.js";
66
import { authenticateAndAuthorizeFromContext } from "../lib/auth.js";
77
import { rsaPublicJwkFromPem } from "../lib/jwk.js";
8-
import { keyIdFromPublicJwk, signCanonicalRs256 } from "../lib/sign-rs256.js";
8+
import { importRsaPrivateKey, keyIdFromPublicJwk, signCanonicalRs256 } from "../lib/sign-rs256.js";
99
import type {
1010
AppendAttestationLedgerEntryInput,
1111
AttestationGrant,
@@ -30,6 +30,8 @@ type FinalizeCommit = {
3030
sha: string;
3131
};
3232

33+
type LedgerSigner = (payload: Record<string, unknown>) => Promise<string>;
34+
3335
const MAX_GRANT_TTL_SECONDS = 24 * 60 * 60;
3436
const DEFAULT_GRANT_TTL_SECONDS = 60 * 60;
3537
const MAX_FINALIZE_COMMITS = 100;
@@ -122,7 +124,8 @@ attestations.post("/grants", async (c) => {
122124
ts: grant.createdAt,
123125
...(grant.taskRef ? { taskRef: grant.taskRef } : {}),
124126
};
125-
const jws = await signLedgerPayload(c, payload);
127+
const signLedgerPayload = await createLedgerSigner(c);
128+
const jws = await signLedgerPayload(payload);
126129
const ledgerEntry: AppendAttestationLedgerEntryInput = {
127130
orgId: grant.orgId,
128131
entryType: late ? "attestation.late" : "attestation.granted",
@@ -184,6 +187,7 @@ attestations.post("/finalize", async (c) => {
184187
}
185188

186189
const ts = new Date().toISOString();
190+
const signLedgerPayload = await createLedgerSigner(c);
187191
const ledgerEntries: AppendAttestationLedgerEntryInput[] = [];
188192
const responseAttestations: Array<{ sha: string; jws: string }> = [];
189193
for (const commit of commits) {
@@ -196,7 +200,7 @@ attestations.post("/finalize", async (c) => {
196200
sponsorId: grant.sponsorId,
197201
ts,
198202
};
199-
const jws = await signLedgerPayload(c, payload);
203+
const jws = await signLedgerPayload(payload);
200204
ledgerEntries.push({
201205
orgId: grant.orgId,
202206
entryType: grant.late ? "attestation.late" : "attestation.issued",
@@ -228,16 +232,24 @@ attestations.post("/finalize", async (c) => {
228232

229233
export default attestations;
230234

231-
async function signLedgerPayload(c: Context<AppEnv>, payload: Record<string, unknown>): Promise<string> {
232-
const privateKey = c.env.RELAYAUTH_SIGNING_KEY_PEM?.trim();
233-
if (!privateKey) {
235+
/**
236+
* Import the signing key and derive its `kid` once per request.
237+
*
238+
* Finalize signs up to MAX_FINALIZE_COMMITS payloads. Resolving the key inside
239+
* that loop would repeat the PKCS#8 import and the JWK thumbprint digest for
240+
* every commit, on the request thread, for no change in output.
241+
*/
242+
async function createLedgerSigner(c: Context<AppEnv>): Promise<LedgerSigner> {
243+
const privateKeyPem = c.env.RELAYAUTH_SIGNING_KEY_PEM?.trim();
244+
if (!privateKeyPem) {
234245
throw new Error("RELAYAUTH_SIGNING_KEY_PEM must be set");
235246
}
236247
const publicKey = c.env.RELAYAUTH_SIGNING_KEY_PEM_PUBLIC?.trim();
237248
const kid = publicKey
238249
? await keyIdFromPublicJwk(await rsaPublicJwkFromPem(publicKey, ""))
239250
: "rs256-key";
240-
return signCanonicalRs256(payload, privateKey, kid);
251+
const privateKey = await importRsaPrivateKey(privateKeyPem);
252+
return (payload) => signCanonicalRs256(payload, privateKey, kid);
241253
}
242254

243255
async function resolveWorkspaceToken(

0 commit comments

Comments
 (0)