From a976182667d188ea90e84e067b0c16bf4375d6f1 Mon Sep 17 00:00:00 2001 From: coodos Date: Tue, 29 Sep 2026 03:23:15 +0800 Subject: [PATCH] fix(auth): only accept PPA certificates from trusted issuers --- .../W3DS Protocol/Platform-Authentication.md | 6 +- packages/auth/src/platform/chain.spec.ts | 83 ++++++++++++++++++- packages/auth/src/platform/chain.ts | 71 +++++++++++++++- packages/auth/src/platform/index.ts | 3 + packages/auth/src/platform/scenario.ts | 3 + services/pp-auth-demo/src/lib/server/chain.ts | 3 +- services/pp-auth-demo/src/lib/server/env.ts | 11 +++ 7 files changed, 173 insertions(+), 7 deletions(-) diff --git a/docs/docs/W3DS Protocol/Platform-Authentication.md b/docs/docs/W3DS Protocol/Platform-Authentication.md index 856a7324a..2ba49d02b 100644 --- a/docs/docs/W3DS Protocol/Platform-Authentication.md +++ b/docs/docs/W3DS Protocol/Platform-Authentication.md @@ -19,10 +19,14 @@ Six links, each failing closed. A verifier checks all six and reports all six | **Bundle integrity** | Both binding documents hash to the values that signature covered, so neither can be swapped independently of the other. | | **Version identity** | The version eName is derivable from the platform eName and version by UUIDv5. Arithmetic, not a lookup — nothing to spoof and no network call. | | **Release authorship** | The release's submission proof re-verifies against its registry key-binding certificate. The same proof the association reviewed, checked again rather than taken on trust. | -| **Accreditation** | The association's ES256 certificate verifies against its JWKS, names this platform as `sub` and this exact version, and grants a level and a set of domains. | +| **Accreditation** | The certificate was issued (`iss`) by a trusted certifying authority, verifies against the JWKS that authority publishes at its own `/.well-known/jwks.json`, names this platform as `sub` and this exact version, and grants a level and a set of domains. | If every link holds, the verifier returns a **claim**: the platform, the deployment, the version, the certification level, and the domains — intersected with what the release actually asked for, so a certificate naming more than the submission requested cannot widen it. +## Trusted certifying authorities + +Only certificates from a recognised Post Platforms Association count. By default that is `https://ppa.w3ds.metastate.foundation` alone; a verifier can set its own list with `trustedPpaIssuers`. The issuer is checked before any key is fetched, and keys always come from the trusted issuer's own JWKS. The `issuerJwksUri` in the evidence is never used for verification, because the deployment being checked supplies it, and a deployment could otherwise point the verifier at a key set it controls and certify itself. + ## The handshake ``` diff --git a/packages/auth/src/platform/chain.spec.ts b/packages/auth/src/platform/chain.spec.ts index dc72e5f16..0b6b72e32 100644 --- a/packages/auth/src/platform/chain.spec.ts +++ b/packages/auth/src/platform/chain.spec.ts @@ -1,6 +1,12 @@ import { createLocalJWKSet } from "jose"; import { describe, expect, it } from "vitest"; -import { softwareVersionEName, verifyDeploymentChain } from "./chain.js"; +import { + DEFAULT_TRUSTED_PPA_ISSUERS, + normalizePpaIssuers, + ppaJwksUri, + softwareVersionEName, + verifyDeploymentChain, +} from "./chain.js"; import { answerChallenge } from "./deployment.js"; import { createChallengeStore, verifyHandshake } from "./handshake.js"; import { @@ -44,6 +50,7 @@ function options(roots: TrustRoots): ChainOptions { registryJwksUri: REGISTRY_JWKS, verifyWalletSignature: roots.verifyWalletSignature, resolveJwks: (uri) => (uri === ISSUER_JWKS ? association : registry), + trustedPpaIssuers: ["https://ppa.example"], }; } @@ -290,3 +297,77 @@ describe("verifyHandshake", () => { expect(store.redeem(issued.nonce)).toBe(false); }); }); + +describe("trusted certifying authorities", () => { + it("refuses a validly signed certificate from an issuer that is not trusted", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment( + roots, + spec({ issuerJwksUri: "https://self-certified.example/.well-known/jwks.json" }), + ); + const association = createLocalJWKSet(roots.association.jwks); + const response = await answerChallenge(identity, challenge()); + + const result = await verifyDeploymentChain(response, { + ...options(roots), + // Even if its keys resolved, an unknown issuer must not count. + resolveJwks: (uri) => + uri === REGISTRY_JWKS ? createLocalJWKSet(roots.registry.jwks) : association, + }); + + expect(result.ok).toBe(false); + expect(result.failedAt).toBe("accreditation"); + expect(result.links.find((l) => l.id === "accreditation")?.detail).toContain( + "not a trusted certifying authority", + ); + }); + + it("verifies against the trusted issuer's own keys, not the key set the evidence names", async () => { + const roots = await createTrustRoots(); + const attacker = await createTrustRoots(roots.wallet.ename); + const { identity } = await mintDeployment(roots, spec()); + // A certificate the attacker signed while claiming to be the trusted PPA, + // pointing the verifier at the attacker's own key set. + const forged = await mintDeployment(attacker, spec()); + identity.evidence.accreditationJws = forged.identity.evidence.accreditationJws; + identity.evidence.issuerJwksUri = "https://attacker.example/.well-known/jwks.json"; + const attackerKeys = createLocalJWKSet(attacker.association.jwks); + const base = options(roots); + const response = await answerChallenge(identity, challenge()); + + const result = await verifyDeploymentChain(response, { + ...base, + resolveJwks: (uri) => + uri === "https://attacker.example/.well-known/jwks.json" + ? attackerKeys + : (base.resolveJwks as NonNullable)(uri), + }); + + expect(result.ok).toBe(false); + expect(result.failedAt).toBe("accreditation"); + }); + + it("trusts only the MetaState PPA by default", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + const { trustedPpaIssuers: _ignored, ...defaults } = options(roots); + const response = await answerChallenge(identity, challenge()); + + const result = await verifyDeploymentChain(response, defaults); + + expect(result.failedAt).toBe("accreditation"); + expect(DEFAULT_TRUSTED_PPA_ISSUERS).toEqual(["https://ppa.w3ds.metastate.foundation"]); + }); + + it("normalises configured issuers to origins", () => { + expect( + normalizePpaIssuers([ + " ppa.w3ds.metastate.foundation/ ", + "http://localhost:4210", + "", + "ftp://nope.example", + ]), + ).toEqual(["https://ppa.w3ds.metastate.foundation", "http://localhost:4210"]); + expect(ppaJwksUri("https://ppa.example")).toBe("https://ppa.example/.well-known/jwks.json"); + }); +}); diff --git a/packages/auth/src/platform/chain.ts b/packages/auth/src/platform/chain.ts index f80ba8049..8c7e7bb46 100644 --- a/packages/auth/src/platform/chain.ts +++ b/packages/auth/src/platform/chain.ts @@ -19,7 +19,7 @@ */ import { createHash } from "node:crypto"; -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose"; import { bindingDocumentHash, canonicalSubmissionStatement, @@ -100,9 +100,48 @@ export interface ChainOptions { * set, share a cache, or run offline. */ resolveJwks?: JwksResolver; + /** + * The PPA services whose certificates count, as hosts or origins. Defaults + * to {@link DEFAULT_TRUSTED_PPA_ISSUERS}. A certificate is only accepted if + * its `iss` is on this list, and it is verified against the keys that issuer + * publishes at `/.well-known/jwks.json` — never against a key set named by + * the evidence, which the deployment itself supplies. + */ + trustedPpaIssuers?: readonly string[]; now?: Date; } +/** The certifying authorities trusted when a verifier configures none. */ +export const DEFAULT_TRUSTED_PPA_ISSUERS: readonly string[] = [ + "https://ppa.w3ds.metastate.foundation", +]; + +/** + * Normalises configured issuers to origins. A bare host means https; http is + * accepted as written, for local development only. + */ +export function normalizePpaIssuers(values: readonly string[]): string[] { + const issuers: string[] = []; + for (const raw of values) { + let value = raw.trim().replace(/\/+$/, ""); + if (!value) continue; + if (!value.includes("://")) value = `https://${value}`; + try { + const url = new URL(value); + if (url.protocol !== "https:" && url.protocol !== "http:") continue; + issuers.push(url.origin); + } catch { + // Ignore entries that are not URLs. + } + } + return issuers; +} + +/** Where a trusted issuer publishes the keys its certificates verify against. */ +export function ppaJwksUri(issuer: string): string { + return new URL("/.well-known/jwks.json", issuer).toString(); +} + async function defaultWalletVerifier( registryBaseUrl: string, ): Promise { @@ -403,12 +442,36 @@ export async function verifyDeploymentChain( let claim: PlatformClaim | null = null; let accredited = false; let accreditationDetail = "No certificate was presented."; - if (evidence.accreditationJws && evidence.issuerJwksUri) { + const trustedIssuers = new Set( + normalizePpaIssuers(options.trustedPpaIssuers ?? DEFAULT_TRUSTED_PPA_ISSUERS), + ); + let claimedIssuer = ""; + if (evidence.accreditationJws) { + try { + claimedIssuer = String(decodeJwt(evidence.accreditationJws).iss ?? ""); + } catch { + // Reported below as a certificate without a trusted issuer. + } + } + const issuer = normalizePpaIssuers([claimedIssuer])[0]; + if (evidence.accreditationJws && (!issuer || !trustedIssuers.has(issuer))) { + // Anyone can sign a certificate; only a recognised certifying authority's + // signature means anything. Checked before any key is fetched, so the + // evidence cannot point the verifier at a key set of its choosing. + accreditationDetail = claimedIssuer + ? `The certificate was issued by ${claimedIssuer}, which is not a trusted certifying authority.` + : "The certificate does not name a trusted certifying authority."; + } else if (evidence.accreditationJws && issuer) { try { const { payload } = await jwtVerify( evidence.accreditationJws, - jwks(evidence.issuerJwksUri), - { algorithms: ["ES256"], subject: evidence.platformEname, currentDate: now }, + jwks(ppaJwksUri(issuer)), + { + algorithms: ["ES256"], + issuer: claimedIssuer, + subject: evidence.platformEname, + currentDate: now, + }, ); const level = String(payload.level ?? "") as CertificationLevel; const domains = Array.isArray(payload.domains) diff --git a/packages/auth/src/platform/index.ts b/packages/auth/src/platform/index.ts index 8ad7b58fd..848052306 100644 --- a/packages/auth/src/platform/index.ts +++ b/packages/auth/src/platform/index.ts @@ -20,6 +20,9 @@ export { export type { P256KeyPair, P256Verification } from "./p256.js"; export { challengePayload, + DEFAULT_TRUSTED_PPA_ISSUERS, + normalizePpaIssuers, + ppaJwksUri, softwareVersionEName, verifyDeploymentChain, } from "./chain.js"; diff --git a/packages/auth/src/platform/scenario.ts b/packages/auth/src/platform/scenario.ts index c163b7be8..d1a2afddf 100644 --- a/packages/auth/src/platform/scenario.ts +++ b/packages/auth/src/platform/scenario.ts @@ -218,6 +218,9 @@ export async function mintDeployment( reviewedBy: spec.reviewedByEName ?? roots.wallet.ename, }) .setProtectedHeader({ alg: "ES256", kid: roots.association.kid }) + // Like the real PPA, the certificate names its issuer; verifiers only + // accept issuers they trust and fetch keys from that issuer. + .setIssuer(new URL(spec.issuerJwksUri).origin) .setSubject(spec.platformEname) .setJti(randomUUID()) .setIssuedAt(now) diff --git a/services/pp-auth-demo/src/lib/server/chain.ts b/services/pp-auth-demo/src/lib/server/chain.ts index 28c28e866..c9b193e4a 100644 --- a/services/pp-auth-demo/src/lib/server/chain.ts +++ b/services/pp-auth-demo/src/lib/server/chain.ts @@ -19,7 +19,7 @@ import { randomUUID } from "node:crypto"; import { verifySignature } from "signature-validator/src/index"; import { accreditations, deployments, platformProfile } from "./aaas"; import { bindingDocuments } from "./evault"; -import { registryUrl } from "./env"; +import { registryUrl, trustedPpaIssuers } from "./env"; import type { AccreditationRecord, DeploymentRecord } from "./ontology"; /** @@ -161,6 +161,7 @@ export async function verify( audience, registryBaseUrl: registryUrl(), verifyWalletSignature, + trustedPpaIssuers: trustedPpaIssuers(), }); // With no key there was nothing to check, which is not the same as a check diff --git a/services/pp-auth-demo/src/lib/server/env.ts b/services/pp-auth-demo/src/lib/server/env.ts index e091fd31c..72a0c6d60 100644 --- a/services/pp-auth-demo/src/lib/server/env.ts +++ b/services/pp-auth-demo/src/lib/server/env.ts @@ -61,3 +61,14 @@ export function jwtSecret(): string { /** Name this app presents to the registry when minting its read token. */ export const PLATFORM_NAME = "pp-auth-demo"; + +/** + * Certifying authorities whose certificates the chain accepts, as a comma + * separated list of hosts or origins. Unset means the library default: only + * the MetaState PPA. Add a local PPA (e.g. http://localhost:4210) for dev. + */ +export function trustedPpaIssuers(): string[] | undefined { + const value = raw("PP_AUTH_TRUSTED_PPA_ISSUERS"); + if (!value) return undefined; + return value.split(",").map((issuer) => issuer.trim()).filter(Boolean); +}