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
6 changes: 5 additions & 1 deletion docs/docs/W3DS Protocol/Platform-Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down
83 changes: 82 additions & 1 deletion packages/auth/src/platform/chain.spec.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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"],
};
}

Expand Down Expand Up @@ -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<typeof base.resolveJwks>)(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");
});
});
71 changes: 67 additions & 4 deletions packages/auth/src/platform/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/

import { createHash } from "node:crypto";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { createRemoteJWKSet, decodeJwt, jwtVerify } from "jose";
import {
bindingDocumentHash,
canonicalSubmissionStatement,
Expand Down Expand Up @@ -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<WalletVerifier> {
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/platform/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions packages/auth/src/platform/scenario.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion services/pp-auth-demo/src/lib/server/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions services/pp-auth-demo/src/lib/server/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading