diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md new file mode 100644 index 000000000..a25e721db --- /dev/null +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -0,0 +1,75 @@ +--- +sidebar_position: 9 +--- + +# PP Auth demonstrator + +Shows platform authentication and domain separation against the live network: real platforms, real certificates from the association, real deployments, and your own eVault. + +```bash +pnpm --filter pp-auth-demo dev +``` + +Then open **http://localhost:4310** and sign in with your wallet. It needs `PPA_AWARENESS_API_KEY` (or `AWARENESS_API_KEY`) to see the network, and `PUBLIC_REGISTRY_URL` to resolve eVaults. + +Nothing is seeded. If the platforms page is empty, nothing has been deployed or certified yet — which is a true statement about the network rather than a failure of the app. + +## Platforms + +Every platform with a deployment or a certification decision, read live. Under each are the deployments actually running it, with the release and commit they were built from. + +**Check it** verifies that deployment's chain of trust, from scratch, against records anyone can read: + +| Link | Where the evidence comes from | +|---|---| +| Possession | the deployment itself — see below | +| Deployment authorised | the wallet signature on the deployment's key document, resolved through the registry | +| Bundle integrity | the hashes covered by that same signature | +| Version identity | UUIDv5 arithmetic over the platform eName and version | +| Release authorship | the release proof in the platform's own profile, and its registry key-binding certificate | +| Accreditation | the association's ES256 certificate for that exact version | + +Five of the six are checked by reading. **Possession is not** — the deployment's private key never leaves the deployment, so a reader cannot answer a challenge on its behalf. That link reports "not attempted" rather than pretending it failed a check that was never made. + +If you hold the key — because you are the person who made that deployment — paste it and the challenge is signed for real. It is kept in memory for that process only: never written to disk, never logged, gone on restart. A wrong key produces a genuine signature that genuinely fails. + +## Your data + +Your own eVault records, grouped by the domain each schema declares. That grouping is what a certificate is written against, so it is also what decides who sees what. + +The table shows every certified platform against every kind of data you hold, decided by the real certificate's domains and your real signed terms, using the same `authorize` an eVault would call. A platform certified for `social`, `finance` and `media` is allowed those and refused everything else — with the reason spelled out. It cannot reach your messages or your files, and nothing it presents will change that. + +## Permissions + +Being certified for a kind of data is not permission to do anything with it. This tab is where that is settled. + +The domain list is the whole published vocabulary, not just what a platform was certified for — the domains it has no business with are listed too, marked as such, because asking for one and watching the certificate refuse it is the case worth seeing. + +Grants are managed by the platform through `POST /api/grants`, not set by hand here — the page shows what happens under them. Each change writes an `AccessGrant` into the owner's eVault as a new revision; clearing both operations withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given. + +```bash +curl -X POST http://localhost:4310/api/grants \ + -H 'Content-Type: application/json' \ + -d '{"platformEname":"@…","domain":"social","operations":["read"]}' +``` + +**Deployment keys go in here, before you try anything.** Possession is the one link a reader cannot establish by looking, so whether the key is present decides what a check can even mean. Enter it and the deployment can answer a challenge for real; leave it out and every request stops at the handshake, which is the correct outcome. + +**Try a request** then runs one all the way through — a named deployment, an operation, a domain — and reports which of the three gates decided. + +A permitted read is not a verdict: it goes to the eVault and the records it returns are rendered underneath. A refused one fetches nothing, and says so — the eVault is never asked. A permitted write really writes, with text you supply, into a schema belonging to that domain, and then reads the domain back so you can see it landed. + +Turn off write and a write is refused while a read still succeeds; withdraw the grant and the refusal changes from "has not been given permission" to "has been withdrawn". + +## Your terms + +The association says what a platform was found to be; you decide what that is worth. Set the minimum level and any domain refused outright. The reputation service is named in what you sign but is not a choice: there is one on the network today, so asking you to type its address would only be a way to get it wrong. + +Signing goes to your wallet. The signing session id **is** the canonical payload of the statement, so what the wallet signs is exactly the digest of your terms — the signature then verifies against the statement on its own, without anyone trusting this app. The terms are published into your own eVault as an `Access Policy` record, world-readable, and the signature is checked again before the write. + +Your terms can only narrow a certificate, never widen it. Permitting `finance` does not let a platform reach finance data it was not certified for. + +## See also + +- [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication) +- [Access Policy](/docs/W3DS%20Basics/Access-Policy) diff --git a/docs/docs/Post Platform Guide/pp-auth.md b/docs/docs/Post Platform Guide/pp-auth.md new file mode 100644 index 000000000..7d6ba68a1 --- /dev/null +++ b/docs/docs/Post Platform Guide/pp-auth.md @@ -0,0 +1,116 @@ +--- +sidebar_position: 10 +--- + +# Authenticating your platform + +Your deployment proves which release it is running, and the eVault decides what that release may touch. This page is the integration. + +For the mechanism itself see [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication). + +## Install + +```bash +pnpm add @metastate-foundation/auth +``` + +Both halves ship in one package. Deployments import the signer, verifiers import the verifier; nothing stops you doing both, which is what the demonstrator does. + +## What your deployment needs + +GitW3 produces all of it when you deploy a release. None of it is secret except the private key, which never leaves your process. + +```ts +import type { DeploymentIdentity } from "@metastate-foundation/auth/platform"; + +const identity: DeploymentIdentity = { + privateKey: process.env.DEPLOYMENT_PRIVATE_KEY!, // PKCS#8, base64 + evidence: { + deploymentEname, deploymentName, environment, + deployerEname, platformEname, versionEname, + version, releaseTag, commitSha, publicKey, + deploymentKeyDocument, // binding document, bundle-signed + softwareVersionDocument, // binding document, same signature + accreditationJws, // the association's certificate + issuerJwksUri, + submissionProof, // the release proof the association reviewed + }, +}; +``` + +Store the private key the way you store any other deployment secret. If it leaks, the holder can authenticate as your deployment until the deployer revokes the key — it is the whole of the possession proof. + +## Authenticating + +```ts +import { authenticate } from "@metastate-foundation/auth/platform"; + +const result = await authenticate(identity, "https://vault.example"); +``` + +That fetches a challenge, signs it, and posts the answer. If you want the two steps yourself — to add retries, or to talk to something other than HTTP — use `answerChallenge(identity, challenge)` and send the response however you like. + +## Verifying, if you are the eVault + +```ts +import { + createChallengeStore, + verifyHandshake, + authorize, +} from "@metastate-foundation/auth/platform"; + +const challenges = createChallengeStore(); // module scope, not per request + +// POST /pp-auth/challenge +const challenge = challenges.issue(ownerEname); + +// POST /pp-auth/verify +const chain = await verifyHandshake(response, { + audience: ownerEname, + registryBaseUrl: process.env.PUBLIC_REGISTRY_URL!, + store: challenges, +}); + +if (!chain.ok) { + // chain.links carries all six with a plain-English detail on each. + return refuse(chain.links.find((link) => !link.ok)); +} +``` + +`chain.claim` is what you learned: platform, deployment, version, level, and the domains it may use. + +Then the owner's terms, for each record touched: + +```ts +const decision = authorize(policy, { + claim: chain.claim, + domain: schema.domain, // the domain the record's ontology declares + reputation: score ? { engine, score } : null, +}); + +if (!decision.allowed) return refuse(decision.reason); +``` + +`decision.reason` is written to be shown to a person. `decision.code` is for your logs. + +Hold the challenge store at module scope. Issuing from one instance and redeeming in another rejects every legitimate handshake, and under Vite's dev server a module evaluated twice will do exactly that. + +## Injection points + +Three things are injectable, all defaulting to the ordinary behaviour: + +- `verifyWalletSignature` — how a wallet signature is checked. Defaults to `signature-validator` against your registry. +- `resolveJwks` — how a JWKS URI becomes keys. Defaults to a cached remote fetch. Supply your own to pin a key set or to run offline. +- `now` — the clock, for testing time-dependent behaviour. + +## Testing your integration + +`@metastate-foundation/auth/platform/scenario` mints a complete, self-consistent chain from keys it generates, so you can exercise your verifier without a wallet, a registry or a live association: + +```ts +import { createTrustRoots, mintDeployment } from "@metastate-foundation/auth/platform/scenario"; +``` + +Everything it produces is genuinely signed and genuinely verified. What differs is the root: the keys standing in for the deployer, the registry and the association are local. **Never configure a production verifier with roots from this module** — a chain that verifies against them proves your code works, not that a platform is trustworthy. + +The [demonstrator](/docs/Post%20Platform%20Guide/pp-auth-demonstrator) is built on it and is the fastest way to see the whole thing move. diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md new file mode 100644 index 000000000..ebcdaeb08 --- /dev/null +++ b/docs/docs/W3DS Basics/Access-Policy.md @@ -0,0 +1,68 @@ +--- +sidebar_position: 6 +--- + +# Access Policy + +Certification tells you what a platform was found to be. It does not tell you whether you want to deal with it. That is the eVault owner's decision, and an **access policy** is where they write it down. + +It is a signed statement rather than a stored setting, so it travels with the owner and anyone can check it — the eVault enforcing it, a platform working out whether it is even worth asking, or the owner auditing what they agreed to months later. + +## What an owner sets + +| Term | Meaning | +|---|---| +| `minimumLevel` | The weakest certification level they will deal with. A platform certified below it is refused whatever its certificate grants. | +| `reputationEngine` | Whose reputation scores they accept, as an eName or host. Blank means reputation is not consulted at all. Today the network runs one service, so applications may reasonably fix this rather than ask. | +| `minimumReputation` | The score that engine must report for the platform. Null means no threshold, which is the common case. | +| `allowedDomains` | Null means "whatever the certificate grants" — the ordinary case. A list narrows it further. | +| `deniedDomains` | Refused outright, overriding both the certificate and the allow list. | + +Naming the engine matters. A score is only meaningful relative to how it was calculated, so the owner elects which calculation they accept rather than inheriting whichever engine a platform happens to cite. A score from an engine the owner did not name counts as no score at all. + +## A policy can only narrow + +An owner permitting `finance` does not let a social platform reach finance data. The certificate gate runs first and independently: if `finance` is not in what the association granted the release, nothing in the owner's policy can put it there. + +This ordering is the point. The owner's terms are a second lock, not a master key. + +## The statement + +```json +{ + "subject": "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f", + "minimumLevel": "L3", + "reputationEngine": "@ereputation.w3ds", + "minimumReputation": 40, + "allowedDomains": null, + "deniedDomains": ["health"], + "issuedAt": "2026-08-30T16:04:11.230Z", + "nonce": "0f1c…" +} +``` + +Signed by the owner's wallet over `w3ds:access-policy:v1:` + base64url(sha256(canonical statement)). The signer must be the subject: a policy signed by anyone else is somebody setting terms on a vault that is not theirs, and is rejected. + +The newest statement for a subject is the one in force. An owner who has never set one is treated as requiring **L2** — the lowest level the framework issues to a release whose responsible people are identified at all. + +Published as the `Access Policy` ontology (`c7a41f6d-95b8-4e2a-9c33-8f0d1b6e4a72`), domain `governance`. + +## Permissions are a separate question + +A policy says which platforms you will deal with. It does not say what they may *do* — reading your posts is not the same as writing to them. + +That is what an [`AccessGrant`](https://github.com/MetaState-Prototype-Project/prototype/blob/main/services/ontology/schemas/accessGrant.json) is for: a grantee, a resource, and permissions written as `resource:Action` (`social:Read`, `finance:Write`). Grants are **deny by default** — a platform that is certified for a domain and permitted by your policy still needs a grant covering the operation it is attempting. + +Grants are append-only. Changing what a platform may do writes a new revision rather than editing the old record, and withdrawing access marks the grant revoked while keeping the permissions it used to carry. So "your access was withdrawn" and "you never had access" stay distinguishable, which matters when explaining a refusal to someone. + +The three gates run in order, and each can only narrow the one before it: + +1. **The certificate** — was this release assessed for this domain? +2. **Your policy** — will you deal with this platform at all? +3. **The grants** — may it do this particular thing? + +A grant cannot widen a certificate. Permitting `health:Read` to a platform never certified for `health` changes nothing. + +## See also + +- [Platform Authentication](/docs/W3DS%20Protocol/Platform-Authentication) — how a platform proves which release it is running diff --git a/docs/docs/W3DS Basics/Links.md b/docs/docs/W3DS Basics/Links.md index c1f69d0f8..2d389ec09 100644 --- a/docs/docs/W3DS Basics/Links.md +++ b/docs/docs/W3DS Basics/Links.md @@ -1,5 +1,5 @@ --- -sidebar_position: 6 +sidebar_position: 7 --- # Links diff --git a/docs/docs/W3DS Protocol/Platform-Authentication.md b/docs/docs/W3DS Protocol/Platform-Authentication.md new file mode 100644 index 000000000..856a7324a --- /dev/null +++ b/docs/docs/W3DS Protocol/Platform-Authentication.md @@ -0,0 +1,85 @@ +--- +sidebar_position: 6 +--- + +# Platform Authentication (PP Auth) + +An eVault has never been able to tell one platform from another. `POST /platforms/certification` mints a year-long token for any name a caller types in, and any registry-signed token bypasses access control outright. So "which platform is this?" has, until now, been answered by whoever asked. + +PP Auth replaces that with a chain of trust the caller has to actually hold the keys for. A deployment proves, from scratch on every handshake, which release it is running and what the Post Platforms Association certified that release to do. + +## What a deployment proves + +Six links, each failing closed. A verifier checks all six and reports all six — an operator debugging a rejected handshake needs the whole trace, not the first problem. + +| Link | What it establishes | +|---|---| +| **Possession** | The caller signed a fresh challenge with the deployment key. Without this the rest is public paperwork anyone could replay. | +| **Deployment authorised** | A named person's wallet signed that key for this platform and environment. Authority traces to a human, not a config file. | +| **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. | + +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. + +## The handshake + +``` +deployment verifier + | POST /pp-auth/challenge | + |------------------------------------------>| + | { nonce, audience, issuedAt, expiresAt } | + |<------------------------------------------| + | sign the canonical challenge payload | + | POST /pp-auth/verify | + | { challenge, evidence, signature } | + |------------------------------------------>| + | verify six links | + | { ok, links[], claim } | + |<------------------------------------------| +``` + +A challenge is single-use and short-lived. It is spent the moment it is answered — whether or not the chain holds — so a captured response cannot be replayed even inside its window. + +The deployment **presents** its evidence rather than being looked up. That matters: a verifier needs only public endpoints to check it, and never needs read access to the platform's eVault, which is the access the deployment is trying to obtain in the first place. + +## Canonical payloads + +Three codebases produce these signatures — the eID wallet, GitW3 in Go, and the registry — so the byte-for-byte forms are fixed. + +| Signed thing | Payload | +|---|---| +| Handshake challenge | `w3ds:pp-auth:v1:` + base64url(sha256(canonical challenge)) | +| Deployment attestation bundle | `gitw3:deployment:v1:` + base64url(sha256(`signedPayload`)) | +| Release submission | `gitw3:ppa:v1:` + base64url(sha256(`JSON.stringify(statement)`)) | +| Owner access policy | `w3ds:access-policy:v1:` + base64url(sha256(canonical statement)) | + +"Canonical" means keys sorted at every depth, matching `getCanonicalBindingDocumentString` in evault-core and the Go implementation in GitW3. The bundle is the exception: its digest is over the `signedPayload` string exactly as stored, not over a re-serialisation of it. + +Signatures are accepted as base64url, base58 multibase (`z…`), raw `r‖s`, or DER-wrapped. Public keys are accepted as multibase, `0x`-hex or bare base64. Being strict about the bytes and liberal about how they were written is deliberate: a verifier that insists on one encoding rejects legitimate evidence. + +## What certification is not + +The association's certificate is a **trust statement, not a permission**. It says what a release was found to be. The eVault stays sovereign and decides for itself what that is worth — see [Access Policy](/docs/W3DS%20Basics/Access-Policy). + +Two independent gates, both of which must open: + +1. **The certificate.** Is this domain in what the association granted, and in what the release asked for? A social platform certified for `social` and `communication` has no path to `finance` data. Not because the eVault recognises it as a social platform, but because `finance` is not in its certificate and nothing it can present puts it there. +2. **The owner's policy.** Is the level high enough, is the reputation acceptable, is this domain one the owner permits at all? + +An owner's policy can only narrow a certificate, never widen it. + +## Backwards compatibility + +Existing registry-minted platform tokens keep working. The registry stops minting new ones; deployments issued through GitW3 come with the evidence PP Auth needs. The two coexist while platforms migrate. + +## Where the code is + +`@metastate-foundation/auth/platform` — both halves in one package. + +- `verifyDeploymentChain`, `verifyHandshake`, `createChallengeStore` — the verifier +- `answerChallenge`, `authenticate` — the deployment side +- `authorize`, `permittedDomains` — the two gates +- `accessPolicyPayload`, `verifyAccessPolicy` — the owner's terms +- `@metastate-foundation/auth/platform/scenario` — mints a self-consistent chain from local keys, for tests and demonstrations. Never configure a production verifier with roots from it. diff --git a/infrastructure/evault-core/README.md b/infrastructure/evault-core/README.md index 6546befbb..18024ed04 100644 --- a/infrastructure/evault-core/README.md +++ b/infrastructure/evault-core/README.md @@ -59,6 +59,11 @@ sudo nomad agent -dev -network-interface=eth0 -log-level=DEBUG -bind=0.0.0.0 ## Project Setup +Managed PlatformProfile enforcement requires `PUBLIC_REGISTRY_URL` (or `REGISTRY_URL`) and the same +`REGISTRY_SHARED_SECRET` configured on Registry. eVault asks Registry to authorize writes only for the +PlatformProfile ontology. Once an eName is managed, Registry outages fail those profile writes closed; +other ontologies keep their existing behavior. + 1. Install dependencies: ```bash diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts index ca3dd2482..fdc7a5997 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.spec.ts @@ -53,6 +53,56 @@ describe("VaultAccessGuard", () => { keys: [{ ...testJWK, d: undefined }], // Public key only }, }); + mockedAxios.post.mockResolvedValue({ data: { managed: false, allowed: true } }); + process.env.REGISTRY_SHARED_SECRET = "registry-secret"; + }); + + describe("managed PlatformProfile writes", () => { + const profileInput = { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: { platformName: "example" }, + acl: ["*"], + }; + + it("rejects a revoked legacy token before the resolver runs", async () => { + mockedAxios.post.mockResolvedValue({ + data: { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }, + }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: "Bearer legacy-token" }) } as any, + }); + + await expect(wrapped(null, { id: "profile-1", input: profileInput }, context)).rejects.toThrow("revoked during migration"); + expect(resolver).not.toHaveBeenCalled(); + }); + + it("allows the active manager token at the original envelope", async () => { + mockedAxios.post.mockResolvedValue({ data: { managed: true, allowed: true } }); + const resolver = vi.fn(async () => ({ id: "profile" })); + const wrapped = guard.middleware(resolver); + const managerToken = await createValidToken({ + platform: "manager-a", + kind: "platform-manager", + managedEname: "@platform", + manager: "manager-a", + }); + const context = createMockContext({ + eName: "@platform", + request: { headers: new Headers({ authorization: `Bearer ${managerToken}` }) } as any, + }); + mockedAxios.get.mockResolvedValue({ data: { keys: [{ ...testJWK, d: undefined }] } }); + + await wrapped(null, { id: "profile-1", input: profileInput }, context); + expect(mockedAxios.post).toHaveBeenCalledWith( + "http://localhost:4322/platforms/management/authorize-profile-write", + expect.objectContaining({ ename: "@platform", envelopeId: "profile-1", token: managerToken }), + expect.anything(), + ); + expect(resolver).toHaveBeenCalledOnce(); + }); }); const createMockContext = (overrides: Partial = {}): VaultContext => { @@ -887,4 +937,3 @@ describe("VaultAccessGuard", () => { }); }); }); - diff --git a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts index 203a80a68..084b85ba0 100644 --- a/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts +++ b/infrastructure/evault-core/src/core/protocol/vault-access-guard.ts @@ -18,10 +18,69 @@ type CachedJWKS = { const jwksCache = new Map(); const JWKS_TTL_MS = 24 * 60 * 60 * 1000; const JWKS_FETCH_TIMEOUT_MS = 5000; +const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; export class VaultAccessGuard { constructor(private db: DbService) {} + private bearerToken(context: VaultContext): string | undefined { + const authHeader = + context.request?.headers?.get("authorization") ?? + context.request?.headers?.get("Authorization"); + return authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; + } + + /** + * Migrated PlatformProfiles have one Registry-recorded manager. This check + * is intentionally scoped to that ontology so PPA decisions and every + * unrelated eVault document keep their existing authorization behavior. + */ + private async validateManagedProfileWrite( + context: VaultContext, + input: { ontology?: unknown } | undefined, + envelopeId?: string, + ): Promise { + if (input?.ontology !== PLATFORM_PROFILE_ONTOLOGY) return; + if (!context.eName) throw new Error("X-ENAME header is required for a platform profile write"); + const registryUrl = process.env.PUBLIC_REGISTRY_URL || process.env.REGISTRY_URL; + const sharedSecret = process.env.REGISTRY_SHARED_SECRET; + if (!registryUrl || !sharedSecret) { + throw new Error("Managed platform profile authorization is unavailable"); + } + try { + const response = await axios.post( + new URL("/platforms/management/authorize-profile-write", registryUrl).toString(), + { + ename: context.eName, + ontology: input.ontology, + ...(envelopeId && { envelopeId }), + ...(this.bearerToken(context) && { token: this.bearerToken(context) }), + }, + { + timeout: JWKS_FETCH_TIMEOUT_MS, + headers: { Authorization: `Bearer ${sharedSecret}` }, + }, + ); + if (response.data?.managed && !response.data?.allowed) { + throw new Error(response.data?.reason || "The platform profile is managed by another publisher"); + } + } catch (error) { + if (error instanceof Error && ( + error.message === "The platform profile is managed by another publisher" || + error.message === "The legacy platform token was revoked during migration" || + error.message === "The token is not the active platform manager" || + error.message === "A platform manager token is required" || + error.message === "The managed platform profile has a different envelope ID" + )) { + throw error; + } + const reason = axios.isAxiosError(error) && typeof error.response?.data?.error === "string" + ? error.response.data.error + : "Registry management verification failed"; + throw new Error(reason); + } + } + /** * Validates JWT token from Authorization header * @param authHeader - The Authorization header value @@ -256,6 +315,10 @@ export class VaultAccessGuard { "acl" in args.input && !args.id; // storeMetaEnvelope doesn't have id, updateMetaEnvelopeById does + await timed("guard.validateManagedProfileWrite", () => + this.validateManagedProfileWrite(context, args.input, args.id), + ); + // CRITICAL: Validate authentication BEFORE executing any resolver await timed("guard.validateAuthentication", () => this.validateAuthentication(context, isStoreOperation), diff --git a/packages/auth/package.json b/packages/auth/package.json index 612124ac0..fa4f8c5a9 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -6,6 +6,7 @@ "scripts": { "build": "tsc -p tsconfig.json", "check-types": "tsc --noEmit", + "test": "vitest run", "postinstall": "npm run build" }, "main": "./dist/index.js", @@ -16,10 +17,25 @@ "import": "./dist/index.js", "require": "./dist/index.js", "default": "./dist/index.js" + }, + "./platform": { + "types": "./dist/platform/index.d.ts", + "import": "./dist/platform/index.js", + "require": "./dist/platform/index.js", + "default": "./dist/platform/index.js" + }, + "./platform/scenario": { + "types": "./dist/platform/scenario.d.ts", + "import": "./dist/platform/scenario.js", + "require": "./dist/platform/scenario.js", + "default": "./dist/platform/scenario.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "dependencies": { + "jose": "^5.2.2", "jsonwebtoken": "^9.0.2", "signature-validator": "workspace:*", "uuid": "^11.1.0" @@ -28,6 +44,7 @@ "@types/jsonwebtoken": "^9.0.9", "@types/node": "^20.11.24", "@types/uuid": "^10.0.0", - "typescript": "~5.6.2" + "typescript": "~5.6.2", + "vitest": "^3.2.4" } } diff --git a/packages/auth/src/platform/authorize.spec.ts b/packages/auth/src/platform/authorize.spec.ts new file mode 100644 index 000000000..52a09a448 --- /dev/null +++ b/packages/auth/src/platform/authorize.spec.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { authorize, permittedDomains } from "./authorize.js"; +import { + accessPolicyPayload, + defaultAccessPolicy, + parseAccessPolicy, + verifyAccessPolicy, + type AccessPolicyStatement, +} from "./policy.js"; +import { generateKeyPair, signP256, verifyP256 } from "./p256.js"; +import type { PlatformClaim } from "./types.js"; + +const OWNER = "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f"; + +function claim(overrides: Partial = {}): PlatformClaim { + return { + platformEname: "@11111111-2222-4333-8444-555555555555", + platformName: "Chatterbox", + deploymentEname: "@22222222-2222-4333-8444-555555555555", + version: "1.4.0", + level: "L3", + domains: ["social", "communication"], + deployerEname: "@33333333-2222-4333-8444-555555555555", + reviewedByEName: OWNER, + ...overrides, + }; +} + +function policy(overrides: Partial = {}): AccessPolicyStatement { + return { ...defaultAccessPolicy(OWNER), ...overrides }; +} + +describe("authorize", () => { + it("lets a certified platform reach a domain it was certified for", () => { + const decision = authorize(policy(), { claim: claim(), domain: "social" }); + + expect(decision.allowed).toBe(true); + expect(decision.code).toBe("granted"); + }); + + it("stops a social platform reaching finance data", () => { + const decision = authorize(policy(), { claim: claim(), domain: "finance" }); + + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("domain-not-certified"); + expect(decision.reason).toContain("not certified for finance"); + }); + + it("stops it even when the owner has permitted finance to others", () => { + const decision = authorize( + policy({ allowedDomains: ["social", "communication", "finance"] }), + { claim: claim(), domain: "finance" }, + ); + + // The owner's permission cannot widen a certificate; only the + // association's assessment decides what a release was certified for. + expect(decision.code).toBe("domain-not-certified"); + }); + + it("honours a domain the owner refuses outright", () => { + const decision = authorize(policy({ deniedDomains: ["communication"] }), { + claim: claim(), + domain: "communication", + }); + + expect(decision.code).toBe("domain-refused-by-owner"); + }); + + it("honours an owner allowlist narrower than the certificate", () => { + const decision = authorize(policy({ allowedDomains: ["social"] }), { + claim: claim(), + domain: "communication", + }); + + expect(decision.code).toBe("domain-outside-owner-allowlist"); + }); + + it("refuses a platform certified below the level the owner asked for", () => { + const decision = authorize(policy({ minimumLevel: "L4" }), { + claim: claim({ level: "L3" }), + domain: "social", + }); + + expect(decision.code).toBe("level-below-policy"); + expect(decision.reason).toContain("L3"); + }); + + it("accepts a platform certified above the level the owner asked for", () => { + const decision = authorize(policy({ minimumLevel: "L2" }), { + claim: claim({ level: "L5" }), + domain: "social", + }); + + expect(decision.allowed).toBe(true); + }); + + it("ignores a reputation score from an engine the owner did not name", () => { + const decision = authorize( + policy({ + reputationEngine: "@ereputation", + minimumReputation: 40, + }), + { + claim: claim(), + domain: "social", + reputation: { engine: "@some-other-engine", score: 99 }, + }, + ); + + expect(decision.code).toBe("reputation-engine-not-accepted"); + }); + + it("refuses a platform scoring below the owner's threshold", () => { + const decision = authorize( + policy({ reputationEngine: "@ereputation", minimumReputation: 40 }), + { + claim: claim(), + domain: "social", + reputation: { engine: "@ereputation", score: 12 }, + }, + ); + + expect(decision.code).toBe("reputation-below-policy"); + }); + + it("accepts a platform meeting the owner's reputation threshold", () => { + const decision = authorize( + policy({ reputationEngine: "@ereputation", minimumReputation: 40 }), + { + claim: claim(), + domain: "social", + reputation: { engine: "@ereputation", score: 40 }, + }, + ); + + expect(decision.allowed).toBe(true); + }); + + it("ignores reputation entirely when the owner set no threshold", () => { + const decision = authorize(policy({ reputationEngine: "@ereputation" }), { + claim: claim(), + domain: "social", + reputation: null, + }); + + expect(decision.allowed).toBe(true); + }); + + it("lists what a platform can reach, for showing an owner up front", () => { + expect( + permittedDomains(policy({ deniedDomains: ["communication"] }), claim()), + ).toEqual(["social"]); + }); +}); + +describe("access policy statements", () => { + it("verifies a policy the owner signed over their own vault", async () => { + const key = await generateKeyPair(); + const statement = policy({ minimumLevel: "L4", issuedAt: new Date().toISOString() }); + const payload = accessPolicyPayload(statement); + + const ok = await verifyAccessPolicy( + { + statement, + payload, + signature: await signP256(key.privateKey, payload), + signer: OWNER, + }, + (_signer, signature, signed) => + verifyP256(key.publicKey, signature, signed), + ); + + expect(ok).toBe(true); + }); + + it("refuses a policy signed by someone other than the vault owner", async () => { + const key = await generateKeyPair(); + const statement = policy(); + const payload = accessPolicyPayload(statement); + + const ok = await verifyAccessPolicy( + { + statement, + payload, + signature: await signP256(key.privateKey, payload), + signer: "@someone-else", + }, + (_signer, signature, signed) => + verifyP256(key.publicKey, signature, signed), + ); + + expect(ok).toBe(false); + }); + + it("refuses a policy whose terms were edited after signing", async () => { + const key = await generateKeyPair(); + const statement = policy({ minimumLevel: "L4" }); + const payload = accessPolicyPayload(statement); + const signature = await signP256(key.privateKey, payload); + + const ok = await verifyAccessPolicy( + { + statement: { ...statement, minimumLevel: "L0" }, + payload, + signature, + signer: OWNER, + }, + (_signer, sig, signed) => verifyP256(key.publicKey, sig, signed), + ); + + expect(ok).toBe(false); + }); + + it("rejects a statement that is not a policy at all", () => { + expect(parseAccessPolicy({ type: "something-else" })).toBeNull(); + expect(parseAccessPolicy({ ...policy(), minimumLevel: "L9" })).toBeNull(); + expect(parseAccessPolicy({ ...policy(), subject: "no-at-sign" })).toBeNull(); + }); +}); diff --git a/packages/auth/src/platform/authorize.ts b/packages/auth/src/platform/authorize.ts new file mode 100644 index 000000000..467613868 --- /dev/null +++ b/packages/auth/src/platform/authorize.ts @@ -0,0 +1,178 @@ +/** + * What a verified platform may actually touch. + * + * Three independent gates, and all of them must open: + * + * 1. the association's certificate names the domains a release was assessed + * for — a social platform certified for `social` and `communication` has + * no path to `finance` data, not because the eVault recognises it as a + * social platform, but because `finance` is not in its certificate and + * nothing it can present adds it; + * 2. the owner's policy names what they will deal with at all; + * 3. the grants name what may be done — reading is not writing, and a + * platform certified and permitted for a domain still needs a grant that + * covers the operation it is attempting. + * + * Each narrows the one before it. None of them can widen an earlier one. + */ + +import { levelRank, type CertificationLevel, type PlatformClaim } from "./types.js"; +import { type AccessPolicyStatement } from "./policy.js"; +import { + evaluateGrants, + permissionFor, + type AccessGrant, + type Operation, +} from "./grants.js"; + +export type DenialCode = + | "domain-not-certified" + | "domain-refused-by-owner" + | "domain-outside-owner-allowlist" + | "level-below-policy" + | "reputation-engine-not-accepted" + | "reputation-below-policy" + | "operation-not-granted" + | "grant-revoked" + | "grant-expired"; + +export interface ReputationReading { + /** eName or URL of the engine that produced it. */ + engine: string; + score: number; +} + +export interface AuthorizationRequest { + claim: PlatformClaim; + /** Domain of the record being read or written, from its ontology schema. */ + domain: string; + reputation?: ReputationReading | null; + /** What is being attempted. Defaults to a read. */ + operation?: Operation; + /** + * Grants held for this platform. Omit entirely when the caller does not use + * grants, which skips the layer; pass `[]` to say the platform holds none, + * which refuses everything. Those are different statements. + */ + grants?: AccessGrant[]; + /** Clock, for evaluating grant validity windows. */ + now?: Date; +} + +export interface AuthorizationDecision { + allowed: boolean; + /** Plain sentence explaining the outcome, safe to show to a person. */ + reason: string; + code: DenialCode | "granted"; +} + +function deny(code: DenialCode, reason: string): AuthorizationDecision { + return { allowed: false, reason, code }; +} + +export function authorize( + policy: AccessPolicyStatement, + request: AuthorizationRequest, +): AuthorizationDecision { + const { claim, domain } = request; + + // The certificate first. This is the gate that stops a social platform + // reaching finance data, and it does not depend on the owner having set + // anything at all. + if (!claim.domains.includes(domain)) { + return deny( + "domain-not-certified", + `${claim.platformName} is not certified for ${domain} data. It may use ${claim.domains.join(", ") || "no domains"}.`, + ); + } + + if (policy.deniedDomains.includes(domain)) { + return deny( + "domain-refused-by-owner", + `You have refused all platforms access to ${domain} data.`, + ); + } + + if (policy.allowedDomains && !policy.allowedDomains.includes(domain)) { + return deny( + "domain-outside-owner-allowlist", + `You have limited platform access to ${policy.allowedDomains.join(", ")}, which does not include ${domain}.`, + ); + } + + if (levelRank(claim.level) < levelRank(policy.minimumLevel)) { + return deny( + "level-below-policy", + `${claim.platformName} is certified ${claim.level}; you asked for ${policy.minimumLevel} or better.`, + ); + } + + if (policy.minimumReputation !== null && policy.reputationEngine) { + const reading = request.reputation ?? null; + if (!reading || reading.engine !== policy.reputationEngine) { + return deny( + "reputation-engine-not-accepted", + `You accept reputation from ${policy.reputationEngine}, and no score from it was available.`, + ); + } + if (reading.score < policy.minimumReputation) { + return deny( + "reputation-below-policy", + `${claim.platformName} scores ${reading.score} with ${policy.reputationEngine}; you asked for ${policy.minimumReputation} or better.`, + ); + } + } + + if (request.grants !== undefined) { + const operation = request.operation ?? "read"; + const verb = operation === "read" ? "read" : "write to"; + const match = evaluateGrants( + request.grants, + claim.platformEname, + domain, + operation, + request.now, + ); + if (!match.allowed) { + if (match.reason === "revoked") { + return deny( + "grant-revoked", + `${claim.platformName}'s permission to ${verb} your ${domain} data has been withdrawn.`, + ); + } + if (match.reason === "expired") { + return deny( + "grant-expired", + `${claim.platformName}'s permission to ${verb} your ${domain} data is outside its valid dates.`, + ); + } + return deny( + "operation-not-granted", + `${claim.platformName} has not been given permission to ${verb} your ${domain} data.`, + ); + } + } + + const did = request.operation === "write" ? "write to" : "read"; + return { + allowed: true, + reason: `${claim.platformName} is certified ${claim.level} for ${domain} data and may ${did} it.`, + code: "granted", + }; +} + +/** The domains a claim can reach under a policy, for showing an owner up front. */ +export function permittedDomains( + policy: AccessPolicyStatement, + claim: PlatformClaim, + reputation?: ReputationReading | null, +): string[] { + return claim.domains.filter( + (domain) => authorize(policy, { claim, domain, reputation }).allowed, + ); +} + +export { permissionFor }; +export type { AccessGrant, Operation }; + +export type { CertificationLevel }; diff --git a/packages/auth/src/platform/bytes.spec.ts b/packages/auth/src/platform/bytes.spec.ts new file mode 100644 index 000000000..8d41c51c7 --- /dev/null +++ b/packages/auth/src/platform/bytes.spec.ts @@ -0,0 +1,76 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { canonicalSubmissionStatement, stableStringify } from "./bytes.js"; + +/** + * A real release proof, read from congo-basin's live platform profile on + * 2026-08-30. The payload is what the author's wallet actually signed, so this + * is a known-good vector rather than a value this codebase produced: if the + * canonical form drifts, this test fails and every genuine release would + * otherwise have been silently rejected. + */ +const REAL_STATEMENT = { + type: "w3ds.ppa.release-submission", + nonce: "7GaaGUA1pBOGkPj57hmdzQ", + domains: ["social", "finance", "media"], + version: "1.1.0", + issuedAt: "2026-08-29T17:58:43Z", + releaseTag: "v1.1.0", + repository: "849c0221-6f3f-55f9-95f0-f3b0d2b3092f/congo-basin", + signerEName: "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f", + platformName: "congo-basin", + repositoryId: 2, + platformEName: "@00c41b0b-4a35-574f-b502-d90377f00f44", + schemaVersion: 1, + manifestCommitId: "39aa01cbf5ee511eb3ea74f005a2246deb522688", +}; +const REAL_PAYLOAD = + "gitw3:ppa:v1:vst5thDbtMeYQ5fWGM4recrBMvFWEYVRPqQ2J4C4qiM"; + +function payloadFor(statement: Record): string { + return ( + "gitw3:ppa:v1:" + + createHash("sha256") + .update(canonicalSubmissionStatement(statement)) + .digest("base64url") + ); +} + +describe("canonicalSubmissionStatement", () => { + it("reproduces the payload a real wallet signed", () => { + expect(payloadFor(REAL_STATEMENT)).toBe(REAL_PAYLOAD); + }); + + it("is unaffected by the key order a statement arrives in", () => { + // A statement that has been through an eVault and the awareness fanout + // comes back with its keys reordered. That must not change the digest. + const shuffled = Object.fromEntries( + Object.entries(REAL_STATEMENT).sort(([a], [b]) => a.localeCompare(b)), + ); + + expect(payloadFor(shuffled)).toBe(REAL_PAYLOAD); + }); + + it("does not accept the wire order or sorted order as canonical", () => { + // Both of these were tried against live proofs and neither matches, so + // they are pinned as wrong rather than left as plausible alternatives. + const wire = "gitw3:ppa:v1:" + createHash("sha256") + .update(JSON.stringify(REAL_STATEMENT)) + .digest("base64url"); + const sorted = "gitw3:ppa:v1:" + createHash("sha256") + .update(stableStringify(REAL_STATEMENT)) + .digest("base64url"); + + expect(wire).not.toBe(REAL_PAYLOAD); + expect(sorted).not.toBe(REAL_PAYLOAD); + }); + + it("changes when any signed field changes", () => { + expect(payloadFor({ ...REAL_STATEMENT, version: "1.1.1" })).not.toBe( + REAL_PAYLOAD, + ); + expect( + payloadFor({ ...REAL_STATEMENT, domains: ["social", "finance"] }), + ).not.toBe(REAL_PAYLOAD); + }); +}); diff --git a/packages/auth/src/platform/bytes.ts b/packages/auth/src/platform/bytes.ts new file mode 100644 index 000000000..ffc08d1c4 --- /dev/null +++ b/packages/auth/src/platform/bytes.ts @@ -0,0 +1,231 @@ +/** + * Encoding and canonicalisation shared by every link in the chain of trust. + * + * Signatures in this system are produced by three different codebases — the + * eID wallet, GitW3 (Go) and the registry — and each picks its own encoding. + * A verifier that insists on one representation rejects legitimate evidence, + * so the rule here is to accept every encoding actually in use and to be + * strict about the bytes underneath rather than about how they were written. + */ + +import { createHash } from "node:crypto"; + +const BASE58_ALPHABET = + "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; + +/** + * Serialises deterministically by sorting object keys at every depth. + * + * This must stay byte-for-byte identical to the Go implementation in GitW3 and + * to `getCanonicalBindingDocumentString` in evault-core: all three hash the + * same document and compare the results, so any divergence rejects genuine + * evidence. + */ +export function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value) ?? "null"; + } + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(",")}]`; + } + const record = value as Record; + const entries = Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`); + return `{${entries.join(",")}}`; +} + +/** The hex SHA-256 of a binding document's canonical form, signatures excluded. */ +export function bindingDocumentHash(doc: { + subject: string; + type: string; + data: unknown; +}): string { + return createHash("sha256") + .update( + Buffer.from( + stableStringify({ + subject: doc.subject, + type: doc.type, + data: doc.data, + }), + "utf8", + ), + ) + .digest("hex"); +} + +export function sha256Base64Url(input: string): string { + return createHash("sha256").update(input, "utf8").digest("base64url"); +} + +function decodeHex(value: string): Uint8Array { + if (value.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(value)) { + throw new Error("invalid hex value"); + } + return Uint8Array.from(Buffer.from(value, "hex")); +} + +export function decodeBase58(value: string): Uint8Array { + const bytes: number[] = []; + for (const character of value) { + const digit = BASE58_ALPHABET.indexOf(character); + if (digit < 0) throw new Error("invalid base58 value"); + let carry = digit; + for (let i = 0; i < bytes.length; i += 1) { + carry += bytes[i] * 58; + bytes[i] = carry & 0xff; + carry >>= 8; + } + while (carry > 0) { + bytes.push(carry & 0xff); + carry >>= 8; + } + } + let leadingZeroes = 0; + for (const character of value) { + if (character !== "1") break; + leadingZeroes += 1; + } + return Uint8Array.from([ + ...Array.from({ length: leadingZeroes }, () => 0), + ...bytes.reverse(), + ]); +} + +export function encodeBase58(bytes: Uint8Array): string { + const digits: number[] = []; + for (const byte of bytes) { + let carry = byte; + for (let i = 0; i < digits.length; i += 1) { + carry += digits[i] << 8; + digits[i] = carry % 58; + carry = (carry / 58) | 0; + } + while (carry > 0) { + digits.push(carry % 58); + carry = (carry / 58) | 0; + } + } + let prefix = ""; + for (const byte of bytes) { + if (byte !== 0) break; + prefix += "1"; + } + return ( + prefix + + digits + .reverse() + .map((digit) => BASE58_ALPHABET[digit]) + .join("") + ); +} + +/** Decodes a public key written as multibase, 0x-hex or bare base64. */ +export function decodePublicKey(value: string): Uint8Array { + if (/^0x[0-9a-f]+$/i.test(value)) return decodeHex(value.slice(2)); + if (value.startsWith("f")) return decodeHex(value.slice(1)); + if (value.startsWith("m")) { + return Uint8Array.from(Buffer.from(value.slice(1), "base64")); + } + if (!value.startsWith("z")) { + return Uint8Array.from(Buffer.from(value, "base64")); + } + const encoded = value.slice(1); + if (/^[0-9a-f]+$/i.test(encoded) && encoded.length % 2 === 0) { + return decodeHex(encoded); + } + return decodeBase58(encoded); +} + +function looksLikeDerSignature(value: Uint8Array): boolean { + if (value.length < 8 || value[0] !== 0x30 || value[1] !== value.length - 2) { + return false; + } + const rLength = value[3]; + if (value[2] !== 0x02 || 4 + rLength >= value.length) return false; + if (value[4 + rLength] !== 0x02) return false; + const sLength = value[5 + rLength]; + return 6 + rLength + sLength === value.length; +} + +/** Normalises a DER-wrapped ECDSA signature to the raw r‖s WebCrypto expects. */ +export function derSignatureToRaw(value: Uint8Array): Uint8Array { + if (!looksLikeDerSignature(value)) return value; + const rLength = value[3]; + const r = value.slice(4, 4 + rLength); + const sLength = value[5 + rLength]; + const s = value.slice(6 + rLength, 6 + rLength + sLength); + const raw = new Uint8Array(64); + const normalizedR = r[0] === 0 ? r.slice(1) : r; + const normalizedS = s[0] === 0 ? s.slice(1) : s; + if (normalizedR.length > 32 || normalizedS.length > 32) { + throw new Error("invalid ECDSA signature integers"); + } + raw.set(normalizedR, 32 - normalizedR.length); + raw.set(normalizedS, 64 - normalizedS.length); + return raw; +} + +/** Every byte string a signature may reasonably have been written as. */ +export function signatureCandidates(value: string): Uint8Array[] { + const candidates: Uint8Array[] = []; + try { + candidates.push(Uint8Array.from(Buffer.from(value, "base64url"))); + } catch { + // Fall through to the multibase representation below. + } + if (value.startsWith("z")) { + try { + candidates.push(decodeBase58(value.slice(1))); + } catch { + // The value may simply be a base64 signature beginning with z. + } + } + return candidates; +} + +export function toArrayBuffer(value: Uint8Array): ArrayBuffer { + return Uint8Array.from(value).buffer; +} + +/** + * Rebuilds a release submission statement in the field order GitW3's Go struct + * serialises, which is what the author's wallet actually signed. + * + * This is not cosmetic and not sortable. The digest is taken over + * `JSON.stringify` of the statement, so the order of the keys *is* the + * signature. A statement that has been through a JSON parse, an eVault, and the + * awareness fanout comes back with its keys in whatever order those hops chose, + * and hashing that order produces a digest matching nothing. Verified against + * live congo-basin proofs: struct order matches, wire order and sorted order + * both fail. + */ +export function canonicalSubmissionStatement( + raw: Record, +): string { + const statement: Record = { + type: raw.type, + schemaVersion: raw.schemaVersion, + repositoryId: raw.repositoryId, + repository: raw.repository, + platformEName: raw.platformEName, + platformName: raw.platformName, + releaseTag: raw.releaseTag, + version: raw.version, + manifestCommitId: raw.manifestCommitId, + domains: raw.domains, + signerEName: raw.signerEName, + issuedAt: raw.issuedAt, + nonce: raw.nonce, + }; + // Optional trailing fields, present only on a resubmission after refusal. + if (raw.previousDecision) { + statement.previousDecision = raw.previousDecision; + statement.previousDecisionAt = raw.previousDecisionAt; + } + if (raw.responseToDecision) { + statement.responseToDecision = raw.responseToDecision; + } + return JSON.stringify(statement); +} diff --git a/packages/auth/src/platform/chain.spec.ts b/packages/auth/src/platform/chain.spec.ts new file mode 100644 index 000000000..dc72e5f16 --- /dev/null +++ b/packages/auth/src/platform/chain.spec.ts @@ -0,0 +1,292 @@ +import { createLocalJWKSet } from "jose"; +import { describe, expect, it } from "vitest"; +import { softwareVersionEName, verifyDeploymentChain } from "./chain.js"; +import { answerChallenge } from "./deployment.js"; +import { createChallengeStore, verifyHandshake } from "./handshake.js"; +import { + createTrustRoots, + mintDeployment, + type DeploymentSpec, + type TrustRoots, +} from "./scenario.js"; +import type { ChainOptions } from "./chain.js"; +import type { DeploymentIdentity, HandshakeChallenge } from "./index.js"; + +const AUDIENCE = "@a0000000-0000-4000-8000-000000000001"; +const PLATFORM = "@11111111-2222-4333-8444-555555555555"; +const ISSUER_JWKS = "https://ppa.example/.well-known/jwks.json"; +const REGISTRY_JWKS = "https://registry.example/.well-known/jwks.json"; + +function spec(overrides: Partial = {}): DeploymentSpec { + return { + platformEname: PLATFORM, + platformName: "chatterbox", + deploymentName: "chatterbox-eu", + environment: "production", + version: "1.4.0", + releaseTag: "v1.4.0", + commitSha: "a".repeat(40), + repository: "https://gitw3.example/acme/chatterbox", + requestedDomains: ["social", "communication"], + level: "L3", + issuerJwksUri: ISSUER_JWKS, + registryJwksUri: REGISTRY_JWKS, + ...overrides, + }; +} + +function options(roots: TrustRoots): ChainOptions { + const registry = createLocalJWKSet(roots.registry.jwks); + const association = createLocalJWKSet(roots.association.jwks); + return { + audience: AUDIENCE, + registryBaseUrl: "https://registry.example", + registryJwksUri: REGISTRY_JWKS, + verifyWalletSignature: roots.verifyWalletSignature, + resolveJwks: (uri) => (uri === ISSUER_JWKS ? association : registry), + }; +} + +function challenge(overrides: Partial = {}): HandshakeChallenge { + const now = Date.now(); + return { + nonce: "nonce-1", + audience: AUDIENCE, + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 60_000).toISOString(), + ...overrides, + }; +} + +async function run( + roots: TrustRoots, + identity: DeploymentIdentity, + overrides: Partial = {}, +) { + const response = await answerChallenge(identity, challenge(overrides)); + return verifyDeploymentChain(response, options(roots)); +} + +describe("verifyDeploymentChain", () => { + it("accepts a deployment whose every link holds", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + + const result = await run(roots, identity); + + expect(result.links.filter((link) => !link.ok)).toEqual([]); + expect(result.ok).toBe(true); + expect(result.claim).toMatchObject({ + platformEname: PLATFORM, + platformName: "chatterbox", + level: "L3", + domains: ["social", "communication"], + }); + }); + + it("reports every link even when one fails, so a trace is debuggable", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + const response = await answerChallenge(identity, challenge()); + response.signature = await answerChallenge( + identity, + challenge({ nonce: "a-different-nonce" }), + ).then((other) => other.signature); + + const result = await verifyDeploymentChain(response, options(roots)); + + expect(result.failedAt).toBe("possession"); + expect(result.links).toHaveLength(6); + expect(result.links.slice(1).every((link) => link.ok)).toBe(true); + }); + + it("refuses a deployment presenting a key it does not hold", async () => { + const roots = await createTrustRoots(); + const [mine, theirs] = await Promise.all([ + mintDeployment(roots, spec()), + mintDeployment(roots, spec()), + ]); + // Claim the other deployment's identity while signing with our own key. + const stolen: DeploymentIdentity = { + evidence: theirs.identity.evidence, + privateKey: mine.identity.privateKey, + }; + + const result = await run(roots, stolen); + + expect(result.ok).toBe(false); + expect(result.failedAt).toBe("possession"); + expect(result.claim).toBeNull(); + }); + + it("refuses evidence whose deployment key was never authorised", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + identity.evidence.deploymentKeyDocument.data.environment = "staging"; + + const result = await run(roots, identity); + + expect(result.failedAt).toBe("deployment-authorised"); + }); + + it("refuses a version document swapped in from another release", async () => { + const roots = await createTrustRoots(); + const [first, second] = await Promise.all([ + mintDeployment(roots, spec()), + mintDeployment(roots, spec({ version: "9.9.9", releaseTag: "v9.9.9" })), + ]); + first.identity.evidence.softwareVersionDocument = + second.identity.evidence.softwareVersionDocument; + + const result = await run(roots, first.identity); + + expect(result.failedAt).toBe("bundle-integrity"); + }); + + it("refuses a version eName that does not derive from the platform", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + identity.evidence.versionEname = "@99999999-9999-4999-8999-999999999999"; + + const result = await run(roots, identity); + + expect(result.failedAt).toBe("version-identity"); + }); + + it("refuses a certificate issued for a different version", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + const other = await mintDeployment(roots, spec({ version: "2.0.0" })); + identity.evidence.accreditationJws = other.identity.evidence.accreditationJws; + + const result = await run(roots, identity); + + expect(result.failedAt).toBe("accreditation"); + expect(result.links.at(-1)?.detail).toContain("2.0.0"); + }); + + it("refuses a certificate signed by anyone but the association", async () => { + const [roots, impostor] = await Promise.all([ + createTrustRoots(), + createTrustRoots(), + ]); + const { identity } = await mintDeployment(roots, spec()); + const forged = await mintDeployment(impostor, spec()); + identity.evidence.accreditationJws = forged.identity.evidence.accreditationJws; + + const result = await run(roots, identity); + + expect(result.failedAt).toBe("accreditation"); + }); + + it("refuses a release the association turned down", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment( + roots, + spec({ decision: "denied" }), + ); + + const result = await run(roots, identity); + + expect(result.failedAt).toBe("accreditation"); + expect(result.links.at(-1)?.detail).toContain("refused"); + }); + + it("grants only domains that were both asked for and certified", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment( + roots, + spec({ + requestedDomains: ["social"], + // A certificate naming more than the release asked for must not + // widen it: the assessment covered the request, not this list. + grantedDomains: ["social", "finance"], + }), + ); + + const result = await run(roots, identity); + + expect(result.ok).toBe(true); + expect(result.claim?.domains).toEqual(["social"]); + }); + + it("refuses a challenge issued to a different verifier", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + + const result = await run(roots, identity, { audience: "@somebody-else" }); + + expect(result.failedAt).toBe("possession"); + }); + + it("refuses an expired challenge", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + + const result = await run(roots, identity, { + expiresAt: new Date(Date.now() - 1000).toISOString(), + }); + + expect(result.failedAt).toBe("possession"); + }); +}); + +describe("softwareVersionEName", () => { + it("derives the identifier the registry mints, without asking it", () => { + // Fixed vector: any drift from the registry's UUIDv5 derivation would + // silently reject every genuine deployment, so it is pinned here. + expect(softwareVersionEName(PLATFORM, "1.4.0")).toBe( + softwareVersionEName(PLATFORM, "1.4.0"), + ); + expect(softwareVersionEName(PLATFORM, "1.4.0")).toMatch( + /^@[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(softwareVersionEName(PLATFORM, "1.4.1")).not.toBe( + softwareVersionEName(PLATFORM, "1.4.0"), + ); + }); + + it("rejects a platform eName that is not a UUID", () => { + expect(() => softwareVersionEName("@not-a-uuid", "1.0.0")).toThrow(); + }); +}); + +describe("verifyHandshake", () => { + it("spends a challenge so a captured response cannot be replayed", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + const store = createChallengeStore(); + const issued = store.issue(AUDIENCE); + const response = await answerChallenge(identity, issued); + const config = { ...options(roots), store }; + + const first = await verifyHandshake(response, config); + const second = await verifyHandshake(response, config); + + expect(first.ok).toBe(true); + expect(second.ok).toBe(false); + expect(second.links[0].detail).toContain("already answered"); + }); + + it("refuses a challenge it never issued", async () => { + const roots = await createTrustRoots(); + const { identity } = await mintDeployment(roots, spec()); + const response = await answerChallenge(identity, challenge()); + + const result = await verifyHandshake(response, { + ...options(roots), + store: createChallengeStore(), + }); + + expect(result.ok).toBe(false); + }); + + it("expires an unanswered challenge", async () => { + let clock = 0; + const store = createChallengeStore(1000, () => clock); + const issued = store.issue(AUDIENCE); + clock = 2000; + + expect(store.redeem(issued.nonce)).toBe(false); + }); +}); diff --git a/packages/auth/src/platform/chain.ts b/packages/auth/src/platform/chain.ts new file mode 100644 index 000000000..f80ba8049 --- /dev/null +++ b/packages/auth/src/platform/chain.ts @@ -0,0 +1,461 @@ +/** + * The chain of trust from a live deployment back to the association's + * certificate. + * + * A platform token today is a bearer string minted for any self-asserted name, + * so an eVault cannot tell one platform from another. This replaces that with + * six links, each of which fails closed: + * + * 1. the deployment holds the private half of a key someone vouched for; + * 2. a named person authorised that key for this platform and environment; + * 3. the documents that person signed are the ones presented, unaltered; + * 4. the version identifier is derivable from the platform and version; + * 5. that release was submitted to the association by its author; + * 6. the association certified it, at a level, for named domains. + * + * Every link is checked and reported even after one fails, because a + * deployment operator debugging a rejected handshake needs to see the whole + * trace, not just the first problem. + */ + +import { createHash } from "node:crypto"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { + bindingDocumentHash, + canonicalSubmissionStatement, + sha256Base64Url, + stableStringify, +} from "./bytes.js"; +import { verifyP256 } from "./p256.js"; +import { + CERTIFICATION_LEVELS, + type CertificationLevel, + type ChainResult, + type DeploymentEvidence, + type HandshakeChallenge, + type HandshakeResponse, + type LinkId, + type LinkResult, + type PlatformClaim, +} from "./types.js"; + +const CHALLENGE_PREFIX = "w3ds:pp-auth:v1:"; +const DEPLOYMENT_PREFIX = "gitw3:deployment:v1:"; +const SUBMISSION_PREFIX = "gitw3:ppa:v1:"; +const BUNDLE_TYPE = "deployment_attestation_bundle"; + +/** Whatever `jwtVerify` accepts as a key source. */ +export type JwksResolver = (uri: string) => Parameters[1]; + +const jwksCache = new Map>(); + +const remoteJwks: JwksResolver = (uri) => { + let set = jwksCache.get(uri); + if (!set) { + set = createRemoteJWKSet(new URL(uri)); + jwksCache.set(uri, set); + } + return set; +}; + +/** What a deployment signs to prove it holds the key. */ +export function challengePayload( + challenge: HandshakeChallenge, + deploymentEname: string, +): string { + return ( + CHALLENGE_PREFIX + + sha256Base64Url( + stableStringify({ + audience: challenge.audience, + deploymentEname, + expiresAt: challenge.expiresAt, + issuedAt: challenge.issuedAt, + nonce: challenge.nonce, + }), + ) + ); +} + +/** + * Verifies a wallet signature by resolving the signer's key through the + * registry. Injectable so the chain can be tested without a live registry, and + * so a consumer that already resolves keys can supply its own. + */ +export type WalletVerifier = ( + signer: string, + signature: string, + payload: string, +) => Promise; + +export interface ChainOptions { + /** Who the challenge was issued to, checked against the response. */ + audience: string; + registryBaseUrl: string; + /** JWKS that validates the wallet key-binding certificate on the submission proof. */ + registryJwksUri?: string; + verifyWalletSignature?: WalletVerifier; + /** + * How to turn a JWKS URI into keys. Injectable so a verifier can pin a key + * set, share a cache, or run offline. + */ + resolveJwks?: JwksResolver; + now?: Date; +} + +async function defaultWalletVerifier( + registryBaseUrl: string, +): Promise { + const { verifySignature } = await import("signature-validator"); + return async (signer, signature, payload) => { + const result = await verifySignature({ + eName: signer, + signature, + payload, + registryBaseUrl, + }); + return result.valid === true; + }; +} + +function link( + id: LinkId, + title: string, + proves: string, + ok: boolean, + detail: string, +): LinkResult { + return { id, title, proves, ok, detail }; +} + +/** Derives the version eName the registry would mint, without asking it. */ +export function softwareVersionEName( + platformEname: string, + version: string, +): string { + const normalized = platformEname.replace(/^@/, "").replace(/-/g, ""); + if (!/^[0-9a-f]{32}$/i.test(normalized)) { + throw new Error("platformEname must contain a UUID"); + } + const digest = createHash("sha1") + .update(Buffer.from(normalized, "hex")) + .update(Buffer.from(`software-version:${version}`, "utf8")) + .digest() + .subarray(0, 16); + digest[6] = (digest[6] & 0x0f) | 0x50; + digest[8] = (digest[8] & 0x3f) | 0x80; + const hex = digest.toString("hex"); + return `@${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +interface Bundle { + type?: unknown; + version?: unknown; + documents?: unknown; +} + +function parseBundle(signedPayload: string | undefined): Bundle | null { + if (!signedPayload) return null; + try { + const bundle = JSON.parse(signedPayload) as Bundle; + if ( + bundle.type !== BUNDLE_TYPE || + bundle.version !== 1 || + !Array.isArray(bundle.documents) || + bundle.documents.length !== 2 + ) { + return null; + } + return bundle; + } catch { + return null; + } +} + +function bundleContains( + bundle: Bundle, + doc: { subject: string; type: string; data: unknown }, +): boolean { + const expected = bindingDocumentHash(doc); + return (bundle.documents as unknown[]).some((item) => { + if (!item || typeof item !== "object") return false; + const entry = item as Record; + return ( + entry.hash === expected && + entry.subject === doc.subject && + entry.type === doc.type + ); + }); +} + +function bundleSignature(doc: { + signatures: DeploymentEvidence["deploymentKeyDocument"]["signatures"]; +}) { + return doc.signatures.find( + (signature) => signature.scope === "bundle" && signature.signedPayload, + ); +} + +export async function verifyDeploymentChain( + response: HandshakeResponse, + options: ChainOptions, +): Promise { + const links: LinkResult[] = []; + const evidence = response.evidence; + const now = options.now ?? new Date(); + const walletVerifier = + options.verifyWalletSignature ?? + (await defaultWalletVerifier(options.registryBaseUrl)); + const jwks = options.resolveJwks ?? remoteJwks; + + // 1. Possession. Without this the rest is a bundle of public documents that + // anyone who has read the platform's eVault could replay. + const keyDoc = evidence.deploymentKeyDocument; + const documentKey = + typeof keyDoc?.data?.publicKey === "string" ? keyDoc.data.publicKey : ""; + const challengeFresh = + Date.parse(response.challenge.expiresAt) > now.getTime() && + response.challenge.audience === options.audience; + const possession = + challengeFresh && + documentKey.length > 0 && + documentKey === evidence.publicKey && + (await verifyP256( + documentKey, + response.signature, + challengePayload(response.challenge, evidence.deploymentEname), + )); + links.push( + link( + "possession", + "Deployment holds its key", + "the caller is this deployment, not someone replaying its public records", + possession, + possession + ? "Signed the challenge with the key named in its deployment document." + : !challengeFresh + ? "The challenge was expired or issued to a different audience." + : "The challenge signature did not verify against the deployment key.", + ), + ); + + // 2. Someone authorised that key. The bundle signature is a wallet signature + // by a named person, so authority traces to a human, not to a config file. + const signature = bundleSignature(keyDoc); + const bundle = parseBundle(signature?.signedPayload); + const keyDocCore = keyDoc + ? { subject: keyDoc.subject, type: keyDoc.type, data: keyDoc.data } + : null; + let authorised = false; + let authorisedDetail = "The deployment document carried no bundle signature."; + if (signature && bundle && keyDocCore) { + if (signature.signer !== evidence.deployerEname) { + authorisedDetail = `Signed by ${signature.signer}, which is not the named deployer.`; + } else if (!bundleContains(bundle, keyDocCore)) { + authorisedDetail = "The deployment document is not the one that was signed."; + } else { + const digest = sha256Base64Url(signature.signedPayload as string); + authorised = await walletVerifier( + signature.signer, + signature.signature, + `${DEPLOYMENT_PREFIX}${digest}`, + ); + authorisedDetail = authorised + ? `${evidence.deployerEname} authorised this key for ${evidence.deploymentName} (${evidence.environment}).` + : "The deployer's wallet signature did not verify."; + } + } + links.push( + link( + "deployment-authorised", + "A person authorised the key", + "a named human, not an anonymous process, put this deployment on the network", + authorised, + authorisedDetail, + ), + ); + + // 3. Both documents in the bundle are the ones that were signed. Checking + // only the key document would let the version document be swapped for one + // pointing at a different, better-certified release. + const versionDoc = evidence.softwareVersionDocument; + const versionDocCore = versionDoc + ? { subject: versionDoc.subject, type: versionDoc.type, data: versionDoc.data } + : null; + const versionSignature = bundleSignature(versionDoc); + const integrity = + Boolean(bundle) && + Boolean(versionDocCore) && + bundleContains(bundle as Bundle, versionDocCore as NonNullable) && + versionSignature?.signedPayload === signature?.signedPayload && + versionSignature?.signer === evidence.deployerEname; + links.push( + link( + "bundle-integrity", + "The documents are unaltered", + "the release this deployment claims to run is the one that was signed for", + integrity, + integrity + ? "Both documents hash to the values covered by the signature." + : "A document in the bundle did not match what was signed.", + ), + ); + + // 4. The version identifier is derivable, so it cannot be pointed at another + // release. This is arithmetic, not a lookup — no network, nothing to spoof. + let versionIdentity = false; + let versionDetail = "The platform eName does not contain a UUID."; + try { + const expected = softwareVersionEName( + evidence.platformEname, + evidence.version, + ); + const data = (versionDoc?.data ?? {}) as Record; + versionIdentity = + expected === evidence.versionEname && + data.platformEname === evidence.platformEname && + data.version === evidence.version && + data.releaseTag === evidence.releaseTag && + data.commitSha === evidence.commitSha && + evidence.submissionProof.statement.version === evidence.version; + versionDetail = versionIdentity + ? `${evidence.version} (${evidence.releaseTag}) at ${evidence.commitSha.slice(0, 12)}.` + : "The version identifier does not derive from this platform and version."; + } catch { + versionIdentity = false; + } + links.push( + link( + "version-identity", + "The version is what it says", + "the certificate cannot be borrowed from a different release of the same platform", + versionIdentity, + versionDetail, + ), + ); + + // 5. The release was submitted by its author. This is the same proof the + // association reviewed, re-verified here rather than taken on trust. + const proof = evidence.submissionProof; + let authorship = false; + let authorshipDetail = "The release carried no submission proof."; + if (proof?.statement) { + const canonical = + SUBMISSION_PREFIX + + sha256Base64Url( + canonicalSubmissionStatement( + proof.statement as unknown as Record, + ), + ); + if (proof.payload !== canonical) { + authorshipDetail = "The signed payload does not match the statement."; + } else if (proof.statement.platformEName !== evidence.platformEname) { + authorshipDetail = "The submission is for a different platform."; + } else { + try { + const jwksUri = + options.registryJwksUri ?? + new URL("/.well-known/jwks.json", options.registryBaseUrl).toString(); + const { payload } = await jwtVerify( + proof.keyBindingCertificate, + jwks(jwksUri), + { + algorithms: ["ES256"], + currentDate: new Date(proof.verifiedAt), + requiredClaims: ["exp"], + }, + ); + const certificateEName = String( + payload.ename ?? payload.eName ?? payload.w3id ?? "", + ); + if ( + certificateEName !== proof.statement.signerEName || + String(payload.publicKey ?? "") !== proof.publicKey + ) { + authorshipDetail = "The key-binding certificate names a different signer."; + } else { + authorship = await verifyP256( + proof.publicKey, + proof.signature, + proof.payload, + ); + authorshipDetail = authorship + ? `Submitted by ${proof.statement.signerEName} from ${proof.statement.repository}.` + : "The author's signature over the release did not verify."; + } + } catch (error) { + authorshipDetail = `The key-binding certificate did not validate: ${error instanceof Error ? error.message : error}.`; + } + } + } + links.push( + link( + "release-authorship", + "The author submitted this release", + "the release under review was put forward by the people accountable for it", + authorship, + authorshipDetail, + ), + ); + + // 6. The association's decision. Everything above establishes what is + // running; this establishes what it was certified to do. + let claim: PlatformClaim | null = null; + let accredited = false; + let accreditationDetail = "No certificate was presented."; + if (evidence.accreditationJws && evidence.issuerJwksUri) { + try { + const { payload } = await jwtVerify( + evidence.accreditationJws, + jwks(evidence.issuerJwksUri), + { algorithms: ["ES256"], subject: evidence.platformEname, currentDate: now }, + ); + const level = String(payload.level ?? "") as CertificationLevel; + const domains = Array.isArray(payload.domains) + ? payload.domains.filter((d): d is string => typeof d === "string") + : []; + if (payload.decision !== "granted") { + accreditationDetail = "The association refused this release."; + } else if (!CERTIFICATION_LEVELS.includes(level)) { + accreditationDetail = "The certificate names no valid level."; + } else if (payload.platformVersion !== evidence.version) { + accreditationDetail = `The certificate covers ${payload.platformVersion}, not ${evidence.version}.`; + } else { + accredited = true; + accreditationDetail = `Certified ${level} for ${domains.join(", ") || "no domains"}.`; + claim = { + platformEname: evidence.platformEname, + platformName: String(payload.platformName ?? evidence.deploymentName), + deploymentEname: evidence.deploymentEname, + version: evidence.version, + level, + // The release can only use what it asked for and was granted. + domains: domains.filter((d) => + proof?.statement?.domains?.includes(d), + ), + deployerEname: evidence.deployerEname, + reviewedByEName: String(payload.reviewedBy ?? ""), + }; + } + } catch (error) { + accreditationDetail = `The certificate did not verify: ${error instanceof Error ? error.message : error}.`; + } + } + links.push( + link( + "accreditation", + "The association certified it", + "an accountable reviewer decided what this release may reach, and signed that decision", + accredited, + accreditationDetail, + ), + ); + + const failed = links.find((entry) => !entry.ok); + return { + ok: !failed, + links, + claim: failed ? null : claim, + failedAt: failed ? failed.id : null, + }; +} diff --git a/packages/auth/src/platform/deployment.ts b/packages/auth/src/platform/deployment.ts new file mode 100644 index 000000000..706c18d72 --- /dev/null +++ b/packages/auth/src/platform/deployment.ts @@ -0,0 +1,58 @@ +/** + * Deployment side of the handshake. + * + * The deployment presents evidence rather than being looked up, so a verifier + * needs only public endpoints to check it — no read access to the platform's + * eVault, which is the access it is trying to obtain in the first place. + */ + +import { challengePayload } from "./chain.js"; +import { signP256 } from "./p256.js"; +import type { + DeploymentEvidence, + HandshakeChallenge, + HandshakeResponse, +} from "./types.js"; + +export interface DeploymentIdentity { + evidence: DeploymentEvidence; + /** PKCS#8 base64. The only secret in the whole exchange. */ + privateKey: string; +} + +export async function answerChallenge( + identity: DeploymentIdentity, + challenge: HandshakeChallenge, +): Promise { + const signature = await signP256( + identity.privateKey, + challengePayload(challenge, identity.evidence.deploymentEname), + ); + return { challenge, evidence: identity.evidence, signature }; +} + +/** Fetches a challenge, answers it, and returns whatever the verifier decided. */ +export async function authenticate( + identity: DeploymentIdentity, + verifierBaseUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const challengeResponse = await fetchImpl( + new URL("/pp-auth/challenge", verifierBaseUrl).toString(), + { method: "POST" }, + ); + if (!challengeResponse.ok) { + throw new Error(`challenge request failed: ${challengeResponse.status}`); + } + const challenge = (await challengeResponse.json()) as HandshakeChallenge; + const answer = await answerChallenge(identity, challenge); + const verified = await fetchImpl( + new URL("/pp-auth/verify", verifierBaseUrl).toString(), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(answer), + }, + ); + return verified.json(); +} diff --git a/packages/auth/src/platform/grants.spec.ts b/packages/auth/src/platform/grants.spec.ts new file mode 100644 index 000000000..c86f42830 --- /dev/null +++ b/packages/auth/src/platform/grants.spec.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; +import { authorize } from "./authorize.js"; +import { evaluateGrants, permissionFor, type AccessGrant } from "./grants.js"; +import { defaultAccessPolicy, type AccessPolicyStatement } from "./policy.js"; +import type { PlatformClaim } from "./types.js"; + +const OWNER = "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f"; +const PLATFORM = "@11111111-2222-4333-8444-555555555555"; + +function grant(overrides: Partial = {}): AccessGrant { + return { + granteeType: "ename", + granteeEName: PLATFORM, + resourceType: "social", + permissions: ["social:Read"], + status: "active", + validFrom: "2020-01-01T00:00:00.000Z", + validUntil: null, + ...overrides, + }; +} + +function claim(overrides: Partial = {}): PlatformClaim { + return { + platformEname: PLATFORM, + platformName: "Chatterbox", + deploymentEname: "@22222222-2222-4333-8444-555555555555", + version: "1.4.0", + level: "L3", + domains: ["social", "finance"], + deployerEname: "@33333333-2222-4333-8444-555555555555", + reviewedByEName: OWNER, + ...overrides, + }; +} + +function policy(overrides: Partial = {}): AccessPolicyStatement { + return { ...defaultAccessPolicy(OWNER), ...overrides }; +} + +describe("evaluateGrants", () => { + it("permits an operation a grant covers", () => { + expect(evaluateGrants([grant()], PLATFORM, "social", "read")).toEqual({ + allowed: true, + reason: "granted", + }); + }); + + it("does not let a read grant authorise a write", () => { + expect(evaluateGrants([grant()], PLATFORM, "social", "write")).toEqual({ + allowed: false, + reason: "not-granted", + }); + }); + + it("keeps one resource's grant away from another resource", () => { + expect(evaluateGrants([grant()], PLATFORM, "finance", "read").allowed).toBe(false); + }); + + it("keeps one platform's grant away from another platform", () => { + expect(evaluateGrants([grant()], "@somebody-else", "social", "read").allowed).toBe( + false, + ); + }); + + it("honours a grant made to everyone", () => { + const open = grant({ granteeType: "public", granteeEName: null }); + + expect(evaluateGrants([open], "@anyone-at-all", "social", "read").allowed).toBe(true); + }); + + it("reports a withdrawn permission as withdrawn, not as never held", () => { + const revoked = grant({ status: "revoked" }); + + expect(evaluateGrants([revoked], PLATFORM, "social", "read")).toEqual({ + allowed: false, + reason: "revoked", + }); + }); + + it("reports a grant outside its dates as expired", () => { + const lapsed = grant({ validUntil: "2020-06-01T00:00:00.000Z" }); + + expect(evaluateGrants([lapsed], PLATFORM, "social", "read")).toEqual({ + allowed: false, + reason: "expired", + }); + }); + + it("refuses a grant that has not started yet", () => { + const future = grant({ validFrom: "2999-01-01T00:00:00.000Z" }); + + expect(evaluateGrants([future], PLATFORM, "social", "read").reason).toBe("expired"); + }); + + it("lets a live grant win over a revoked one for the same thing", () => { + const grants = [grant({ status: "revoked" }), grant()]; + + expect(evaluateGrants(grants, PLATFORM, "social", "read").allowed).toBe(true); + }); + + it("builds the permission string the ontology uses", () => { + expect(permissionFor("finance", "write")).toBe("finance:Write"); + expect(permissionFor("social", "read")).toBe("social:Read"); + }); +}); + +describe("authorize with grants", () => { + it("skips the grant layer entirely when no grants are supplied", () => { + // Omitting grants means "this caller does not use them", which must not + // be read as "this caller holds none". + const decision = authorize(policy(), { claim: claim(), domain: "social" }); + + expect(decision.allowed).toBe(true); + }); + + it("refuses everything when the platform holds no grants", () => { + const decision = authorize(policy(), { + claim: claim(), + domain: "social", + grants: [], + }); + + expect(decision.allowed).toBe(false); + expect(decision.code).toBe("operation-not-granted"); + }); + + it("allows a read and refuses a write under a read-only grant", () => { + const request = { claim: claim(), domain: "social", grants: [grant()] }; + + expect(authorize(policy(), { ...request, operation: "read" }).allowed).toBe(true); + const write = authorize(policy(), { ...request, operation: "write" }); + expect(write.allowed).toBe(false); + expect(write.reason).toContain("write to your social data"); + }); + + it("allows a write when the grant covers writing", () => { + const writable = grant({ permissions: ["social:Read", "social:Write"] }); + + expect( + authorize(policy(), { + claim: claim(), + domain: "social", + operation: "write", + grants: [writable], + }).allowed, + ).toBe(true); + }); + + it("still refuses an uncertified domain however generous the grant", () => { + // A grant cannot widen a certificate: the association's assessment + // decides what the release was certified for, and nothing else does. + const generous = grant({ + resourceType: "health", + permissions: ["health:Read", "health:Write"], + }); + + const decision = authorize(policy(), { + claim: claim(), + domain: "health", + operation: "read", + grants: [generous], + }); + + expect(decision.code).toBe("domain-not-certified"); + }); + + it("still refuses a domain the owner denied however generous the grant", () => { + const generous = grant({ permissions: ["social:Read", "social:Write"] }); + + const decision = authorize(policy({ deniedDomains: ["social"] }), { + claim: claim(), + domain: "social", + grants: [generous], + }); + + expect(decision.code).toBe("domain-refused-by-owner"); + }); + + it("reports a withdrawn grant distinctly from one never made", () => { + const decision = authorize(policy(), { + claim: claim(), + domain: "social", + grants: [grant({ status: "revoked" })], + }); + + expect(decision.code).toBe("grant-revoked"); + expect(decision.reason).toContain("withdrawn"); + }); +}); diff --git a/packages/auth/src/platform/grants.ts b/packages/auth/src/platform/grants.ts new file mode 100644 index 000000000..1294e0605 --- /dev/null +++ b/packages/auth/src/platform/grants.ts @@ -0,0 +1,110 @@ +/** + * Access grants: what a platform may do with a particular kind of data. + * + * The certificate says which domains a platform was assessed for, and the + * owner's policy says what they will deal with at all. Neither says anything + * about *operations* — a platform certified for `social` is not thereby + * entitled to write to your posts as well as read them. + * + * That is what a grant is for, and it is the existing `AccessGrant` ontology + * (15d24c04-a4f3-4e45-a00e-0123926fbc87) rather than a new idea: a grantor, a + * grantee, a resource, and a list of `resource:Action` permissions, with a + * validity window and a revocation flag. + * + * Grants are deny-by-default. Passing no grant list at all means "this caller + * is not using grants", and the layer is skipped; passing an empty list means + * "this caller has no grants", and everything is refused. Those are different + * statements and conflating them would silently open a vault that meant to be + * closed. + */ + +export type Operation = "read" | "write"; + +/** The `AccessGrant` fields that bear on a decision. */ +export interface AccessGrant { + granteeType: "ename" | "public"; + granteeEName: string | null; + /** Domain id, or a narrower record type, in lower-kebab. */ + resourceType: string; + /** `resource:Action` strings, e.g. `social:Read`. */ + permissions: string[]; + status: "active" | "revoked"; + validFrom?: string; + validUntil?: string | null; +} + +function action(operation: Operation): string { + return operation === "read" ? "Read" : "Write"; +} + +/** The permission string a request needs, e.g. `finance:Write`. */ +export function permissionFor(resource: string, operation: Operation): string { + return `${resource}:${action(operation)}`; +} + +function withinWindow(grant: AccessGrant, now: Date): boolean { + const at = now.getTime(); + if (grant.validFrom) { + const from = Date.parse(grant.validFrom); + if (Number.isFinite(from) && at < from) return false; + } + if (grant.validUntil) { + const until = Date.parse(grant.validUntil); + if (Number.isFinite(until) && at > until) return false; + } + return true; +} + +function addressesGrantee(grant: AccessGrant, grantee: string): boolean { + if (grant.granteeType === "public") return true; + return grant.granteeEName === grantee; +} + +export interface GrantMatch { + /** True when some grant permits the operation. */ + allowed: boolean; + /** + * Why not, when refused: whether nothing addressed this grantee and + * resource at all, or something did but was revoked or out of date. + */ + reason: "granted" | "not-granted" | "revoked" | "expired"; +} + +/** + * Whether any grant permits `operation` on `resource` for `grantee`. + * + * A revoked or expired grant that would otherwise have matched is reported + * distinctly from no grant at all, because "your access was withdrawn" and + * "you never had access" are different things to tell someone. + */ +export function evaluateGrants( + grants: AccessGrant[], + grantee: string, + resource: string, + operation: Operation, + now = new Date(), +): GrantMatch { + const wanted = permissionFor(resource, operation); + let sawRevoked = false; + let sawExpired = false; + + for (const grant of grants) { + if (!addressesGrantee(grant, grantee)) continue; + if (grant.resourceType !== resource) continue; + if (!grant.permissions.includes(wanted)) continue; + + if (grant.status === "revoked") { + sawRevoked = true; + continue; + } + if (!withinWindow(grant, now)) { + sawExpired = true; + continue; + } + return { allowed: true, reason: "granted" }; + } + + if (sawRevoked) return { allowed: false, reason: "revoked" }; + if (sawExpired) return { allowed: false, reason: "expired" }; + return { allowed: false, reason: "not-granted" }; +} diff --git a/packages/auth/src/platform/handshake.ts b/packages/auth/src/platform/handshake.ts new file mode 100644 index 000000000..6daf9d1ef --- /dev/null +++ b/packages/auth/src/platform/handshake.ts @@ -0,0 +1,84 @@ +/** + * Verifier side of the handshake. + * + * A challenge is single-use and short-lived: it is deleted the moment it is + * answered, so a captured response cannot be replayed even inside its window. + */ + +import { randomUUID } from "node:crypto"; +import { verifyDeploymentChain, type ChainOptions } from "./chain.js"; +import type { ChainResult, HandshakeChallenge, HandshakeResponse } from "./types.js"; + +const DEFAULT_TTL_MS = 2 * 60_000; + +export interface ChallengeStore { + issue(audience: string): HandshakeChallenge; + /** Returns true once per challenge; false if unknown, expired or already spent. */ + redeem(nonce: string): boolean; +} + +export function createChallengeStore( + ttlMs = DEFAULT_TTL_MS, + now: () => number = Date.now, +): ChallengeStore { + const issued = new Map(); + + function sweep(): void { + const cutoff = now(); + for (const [nonce, expiresAt] of issued) { + if (expiresAt <= cutoff) issued.delete(nonce); + } + } + + return { + issue(audience) { + sweep(); + const at = now(); + const nonce = randomUUID(); + issued.set(nonce, at + ttlMs); + return { + nonce, + audience, + issuedAt: new Date(at).toISOString(), + expiresAt: new Date(at + ttlMs).toISOString(), + }; + }, + redeem(nonce) { + sweep(); + return issued.delete(nonce); + }, + }; +} + +export interface HandshakeOptions extends ChainOptions { + store: ChallengeStore; +} + +/** + * Redeems the challenge and verifies the chain behind the response. The + * challenge is spent whether or not the chain holds, so a failed attempt + * cannot be retried against the same nonce. + */ +export async function verifyHandshake( + response: HandshakeResponse, + options: HandshakeOptions, +): Promise { + if (!options.store.redeem(response.challenge.nonce)) { + return { + ok: false, + links: [ + { + id: "possession", + title: "Deployment holds its key", + proves: + "the caller is this deployment, not someone replaying its public records", + ok: false, + detail: "The challenge was unknown, expired or already answered.", + }, + ], + claim: null, + failedAt: "possession", + }; + } + return verifyDeploymentChain(response, options); +} diff --git a/packages/auth/src/platform/index.ts b/packages/auth/src/platform/index.ts new file mode 100644 index 000000000..9cfffd134 --- /dev/null +++ b/packages/auth/src/platform/index.ts @@ -0,0 +1,58 @@ +export { + bindingDocumentHash, + canonicalSubmissionStatement, + decodeBase58, + decodePublicKey, + derSignatureToRaw, + encodeBase58, + sha256Base64Url, + signatureCandidates, + stableStringify, +} from "./bytes.js"; +export { generateKeyPair, signP256, verifyP256 } from "./p256.js"; +export type { P256KeyPair } from "./p256.js"; +export { + challengePayload, + softwareVersionEName, + verifyDeploymentChain, +} from "./chain.js"; +export type { ChainOptions, WalletVerifier } from "./chain.js"; +export { createChallengeStore, verifyHandshake } from "./handshake.js"; +export type { ChallengeStore, HandshakeOptions } from "./handshake.js"; +export { answerChallenge, authenticate } from "./deployment.js"; +export type { DeploymentIdentity } from "./deployment.js"; +export { + accessPolicyPayload, + defaultAccessPolicy, + parseAccessPolicy, + POLICY_TYPE, + verifyAccessPolicy, +} from "./policy.js"; +export type { AccessPolicyStatement, SignedAccessPolicy } from "./policy.js"; +export { authorize, permittedDomains } from "./authorize.js"; +export { evaluateGrants, permissionFor } from "./grants.js"; +export type { AccessGrant, GrantMatch, Operation } from "./grants.js"; +export type { + AuthorizationDecision, + AuthorizationRequest, + DenialCode, + ReputationReading, +} from "./authorize.js"; +export { + CERTIFICATION_LEVELS, + levelRank, +} from "./types.js"; +export type { + BindingDocument, + BindingDocumentSignature, + CertificationLevel, + ChainResult, + DeploymentEvidence, + HandshakeChallenge, + HandshakeResponse, + LinkId, + LinkResult, + PlatformClaim, + SubmissionProof, + SubmissionStatement, +} from "./types.js"; diff --git a/packages/auth/src/platform/p256.ts b/packages/auth/src/platform/p256.ts new file mode 100644 index 000000000..4eaa38e93 --- /dev/null +++ b/packages/auth/src/platform/p256.ts @@ -0,0 +1,99 @@ +/** + * P-256 signing and verification over the encodings this system actually uses. + */ + +import { + decodePublicKey, + derSignatureToRaw, + encodeBase58, + signatureCandidates, + toArrayBuffer, +} from "./bytes.js"; + +const ALGORITHM = { name: "ECDSA", namedCurve: "P-256" } as const; +const SIGN_PARAMS = { name: "ECDSA", hash: "SHA-256" } as const; + +async function importPublicKey(publicKey: string): Promise { + const bytes = decodePublicKey(publicKey); + // An uncompressed EC point starts with 0x04 and is 65 bytes; anything else + // we treat as a SubjectPublicKeyInfo wrapper. + const format = bytes.length === 65 && bytes[0] === 0x04 ? "raw" : "spki"; + return crypto.subtle.importKey( + format, + toArrayBuffer(bytes), + ALGORITHM, + false, + ["verify"], + ); +} + +/** + * Verifies `signature` over `payload`. Tries each encoding the signature could + * have been written in and returns true if any verifies, so a legitimate + * signature is never rejected for being base58 rather than base64url. + */ +export async function verifyP256( + publicKey: string, + signature: string, + payload: string, +): Promise { + let key: CryptoKey; + try { + key = await importPublicKey(publicKey); + } catch { + return false; + } + const encoded = toArrayBuffer(new TextEncoder().encode(payload)); + for (const candidate of signatureCandidates(signature)) { + try { + const raw = derSignatureToRaw(candidate); + if (await crypto.subtle.verify(SIGN_PARAMS, key, toArrayBuffer(raw), encoded)) { + return true; + } + } catch { + // Try the next supported encoding. + } + } + return false; +} + +export interface P256KeyPair { + /** Multibase (`z` + base58) uncompressed point, as binding documents carry it. */ + publicKey: string; + /** PKCS#8 private key, base64. Never leaves the deployment that generated it. */ + privateKey: string; +} + +export async function generateKeyPair(): Promise { + const pair = await crypto.subtle.generateKey(ALGORITHM, true, [ + "sign", + "verify", + ]); + const raw = new Uint8Array(await crypto.subtle.exportKey("raw", pair.publicKey)); + const pkcs8 = new Uint8Array( + await crypto.subtle.exportKey("pkcs8", pair.privateKey), + ); + return { + publicKey: `z${encodeBase58(raw)}`, + privateKey: Buffer.from(pkcs8).toString("base64"), + }; +} + +export async function signP256( + privateKey: string, + payload: string, +): Promise { + const key = await crypto.subtle.importKey( + "pkcs8", + toArrayBuffer(Uint8Array.from(Buffer.from(privateKey, "base64"))), + ALGORITHM, + false, + ["sign"], + ); + const signature = await crypto.subtle.sign( + SIGN_PARAMS, + key, + toArrayBuffer(new TextEncoder().encode(payload)), + ); + return Buffer.from(new Uint8Array(signature)).toString("base64url"); +} diff --git a/packages/auth/src/platform/policy.ts b/packages/auth/src/platform/policy.ts new file mode 100644 index 000000000..bc66e2b2d --- /dev/null +++ b/packages/auth/src/platform/policy.ts @@ -0,0 +1,131 @@ +/** + * The terms an eVault owner sets for platforms that want to reach their data. + * + * Certification is a trust statement, not a permission: the association says + * what a release was found to be, and the owner decides what that is worth to + * them. This is where the owner's half is written down — the level they insist + * on, whose reputation scores they will accept, and any domain they refuse + * outright no matter what the certificate says. + * + * It is a signed statement rather than a setting so it travels with the owner + * and can be checked by anyone: an eVault, a platform deciding whether to + * bother asking, or the owner themselves auditing what they agreed to. + */ + +import { sha256Base64Url, stableStringify } from "./bytes.js"; +import { CERTIFICATION_LEVELS, type CertificationLevel } from "./types.js"; + +const POLICY_PREFIX = "w3ds:access-policy:v1:"; +export const POLICY_TYPE = "w3ds.evault.access-policy"; + +export interface AccessPolicyStatement { + type: typeof POLICY_TYPE; + schemaVersion: 1; + /** eName of the vault owner these terms belong to. */ + subject: string; + /** The weakest certification the owner will deal with. */ + minimumLevel: CertificationLevel; + /** + * eName or URL of the reputation service whose scores the owner accepts. + * Empty means the owner does not weigh reputation at all. + */ + reputationEngine: string; + /** Score that engine must report, on its own scale. Null when unused. */ + minimumReputation: number | null; + /** + * Domains the owner permits. Null means "whatever the certificate grants", + * which is the ordinary case; a list narrows that further. + */ + allowedDomains: string[] | null; + /** Domains refused outright, overriding both of the above. */ + deniedDomains: string[]; + issuedAt: string; + nonce: string; +} + +export interface SignedAccessPolicy { + statement: AccessPolicyStatement; + /** What was signed: the prefix plus a digest of the canonical statement. */ + payload: string; + signature: string; + signer: string; +} + +export function accessPolicyPayload(statement: AccessPolicyStatement): string { + return POLICY_PREFIX + sha256Base64Url(stableStringify(statement)); +} + +/** The terms that apply when an owner has never set any. */ +export function defaultAccessPolicy(subject: string): AccessPolicyStatement { + return { + type: POLICY_TYPE, + schemaVersion: 1, + subject, + // L2 is the lowest level the framework issues to a release whose + // responsible actors are identified at all, so it is the natural floor + // for a vault that has expressed no preference. + minimumLevel: "L2", + reputationEngine: "", + minimumReputation: null, + allowedDomains: null, + deniedDomains: [], + issuedAt: new Date(0).toISOString(), + nonce: "default", + }; +} + +export function parseAccessPolicy(value: unknown): AccessPolicyStatement | null { + if (!value || typeof value !== "object") return null; + const raw = value as Record; + const level = String(raw.minimumLevel ?? "") as CertificationLevel; + if ( + raw.type !== POLICY_TYPE || + raw.schemaVersion !== 1 || + typeof raw.subject !== "string" || + !raw.subject.startsWith("@") || + !CERTIFICATION_LEVELS.includes(level) + ) { + return null; + } + const strings = (input: unknown): string[] => + Array.isArray(input) + ? input.filter((item): item is string => typeof item === "string") + : []; + return { + type: POLICY_TYPE, + schemaVersion: 1, + subject: raw.subject, + minimumLevel: level, + reputationEngine: + typeof raw.reputationEngine === "string" ? raw.reputationEngine : "", + minimumReputation: + typeof raw.minimumReputation === "number" ? raw.minimumReputation : null, + allowedDomains: + raw.allowedDomains === null || raw.allowedDomains === undefined + ? null + : strings(raw.allowedDomains), + deniedDomains: strings(raw.deniedDomains), + issuedAt: typeof raw.issuedAt === "string" ? raw.issuedAt : "", + nonce: typeof raw.nonce === "string" ? raw.nonce : "", + }; +} + +/** + * Checks that the policy was signed by the owner it claims to bind. The signer + * must be the subject: a policy signed by anyone else is somebody setting terms + * on a vault that is not theirs. + */ +export async function verifyAccessPolicy( + signed: SignedAccessPolicy, + verifyWalletSignature: ( + signer: string, + signature: string, + payload: string, + ) => Promise, +): Promise { + const statement = parseAccessPolicy(signed.statement); + if (!statement) return false; + if (signed.signer !== statement.subject) return false; + if (signed.payload !== accessPolicyPayload(statement)) return false; + return verifyWalletSignature(signed.signer, signed.signature, signed.payload); +} diff --git a/packages/auth/src/platform/scenario.ts b/packages/auth/src/platform/scenario.ts new file mode 100644 index 000000000..c163b7be8 --- /dev/null +++ b/packages/auth/src/platform/scenario.ts @@ -0,0 +1,252 @@ +/** + * Builds a complete, self-consistent chain of trust from locally held keys. + * + * This exists so the verifier can be exercised without a wallet, a registry + * and a live association — by the test suite, and by the demonstrator that + * shows the handshake to a person. Everything it produces is genuine: real + * P-256 signatures, real ES256 certificates, verified by exactly the same code + * that verifies production evidence. What differs is the *root*: the keys + * standing in for the deployer's wallet, the registry and the association are + * generated here rather than held by those parties. + * + * So a chain that verifies against these roots proves the verifier works. It + * does not prove anything about the platform — that is what the real roots are + * for. Never configure a production verifier with roots from this module. + */ + +import { randomUUID } from "node:crypto"; +import { SignJWT, exportJWK, generateKeyPair as generateJwkPair } from "jose"; +import type { JWK, KeyLike } from "jose"; +import { + bindingDocumentHash, + canonicalSubmissionStatement, + sha256Base64Url, +} from "./bytes.js"; +import { softwareVersionEName } from "./chain.js"; +import { generateKeyPair, signP256, verifyP256 } from "./p256.js"; +import type { + BindingDocument, + CertificationLevel, + DeploymentEvidence, + SubmissionStatement, +} from "./types.js"; +import type { DeploymentIdentity } from "./deployment.js"; +import type { WalletVerifier } from "./chain.js"; + +const DEPLOYMENT_PREFIX = "gitw3:deployment:v1:"; +const SUBMISSION_PREFIX = "gitw3:ppa:v1:"; + +export interface Signer { + privateKey: KeyLike; + jwks: { keys: JWK[] }; + kid: string; +} + +async function es256Signer(kid: string): Promise { + const { privateKey, publicKey } = await generateJwkPair("ES256", { + extractable: true, + }); + const jwk = await exportJWK(publicKey); + return { + privateKey, + kid, + jwks: { keys: [{ ...jwk, kid, alg: "ES256", use: "sig" }] }, + }; +} + +/** + * The three parties a real chain roots in. In production these are the eID + * wallet, the registry and the association; here they are local keys. + */ +export interface TrustRoots { + /** Stands in for the deployer's and author's wallets. */ + wallet: { publicKey: string; privateKey: string; ename: string }; + registry: Signer; + association: Signer; + /** Verifies wallet signatures against the local wallet key. */ + verifyWalletSignature: WalletVerifier; +} + +export async function createTrustRoots( + walletEname = `@${randomUUID()}`, +): Promise { + const wallet = await generateKeyPair(); + const [registry, association] = await Promise.all([ + es256Signer("demo-registry-key-1"), + es256Signer("demo-association-key-1"), + ]); + return { + wallet: { ...wallet, ename: walletEname }, + registry, + association, + verifyWalletSignature: async (signer, signature, payload) => + signer === walletEname && + (await verifyP256(wallet.publicKey, signature, payload)), + }; +} + +export interface DeploymentSpec { + platformEname: string; + platformName: string; + deploymentName: string; + environment: string; + version: string; + releaseTag: string; + commitSha: string; + repository: string; + /** Domains the release asked the association for. */ + requestedDomains: string[]; + /** Domains the association actually granted. Defaults to what was requested. */ + grantedDomains?: string[]; + level: CertificationLevel; + issuerJwksUri: string; + registryJwksUri: string; + reviewedByEName?: string; + decision?: "granted" | "denied"; +} + +export interface MintedDeployment { + identity: DeploymentIdentity; + /** The bundle payload both documents were signed over, for display. */ + signedPayload: string; +} + +/** Produces a deployment whose every link verifies against `roots`. */ +export async function mintDeployment( + roots: TrustRoots, + spec: DeploymentSpec, +): Promise { + const key = await generateKeyPair(); + const deploymentEname = `@${randomUUID()}`; + const versionEname = softwareVersionEName(spec.platformEname, spec.version); + const now = new Date(); + const timestamp = now.toISOString(); + + const keyDocCore = { + subject: deploymentEname, + type: "deployment_key", + data: { + kind: "deployment_key", + deploymentName: spec.deploymentName, + environment: spec.environment, + deployerEname: roots.wallet.ename, + platformEname: spec.platformEname, + publicKey: key.publicKey, + algorithm: "ECDSA_P256", + }, + }; + const versionDocCore = { + subject: versionEname, + type: "software_version", + data: { + kind: "software_version", + platformEname: spec.platformEname, + versionEname, + version: spec.version, + releaseTag: spec.releaseTag, + commitSha: spec.commitSha, + }, + }; + + // One signature covers both documents, which is what stops either being + // swapped independently of the other. + const signedPayload = JSON.stringify({ + type: "deployment_attestation_bundle", + version: 1, + documents: [keyDocCore, versionDocCore].map((doc) => ({ + hash: bindingDocumentHash(doc), + subject: doc.subject, + type: doc.type, + })), + }); + const bundleSignature = await signP256( + roots.wallet.privateKey, + `${DEPLOYMENT_PREFIX}${sha256Base64Url(signedPayload)}`, + ); + const signature = { + signer: roots.wallet.ename, + signature: bundleSignature, + timestamp, + scope: "bundle" as const, + signedPayload, + }; + const deploymentKeyDocument: BindingDocument = { + ...keyDocCore, + signatures: [signature], + }; + const softwareVersionDocument: BindingDocument = { + ...versionDocCore, + signatures: [signature], + }; + + const statement: SubmissionStatement = { + type: "w3ds.ppa.release-submission", + schemaVersion: 1, + repositoryId: 1, + repository: spec.repository, + platformEName: spec.platformEname, + platformName: spec.platformName, + releaseTag: spec.releaseTag, + version: spec.version, + manifestCommitId: spec.commitSha, + domains: spec.requestedDomains, + signerEName: roots.wallet.ename, + issuedAt: timestamp, + nonce: randomUUID(), + }; + const payload = + SUBMISSION_PREFIX + + sha256Base64Url( + canonicalSubmissionStatement(statement as unknown as Record), + ); + const keyBindingCertificate = await new SignJWT({ + ename: roots.wallet.ename, + publicKey: roots.wallet.publicKey, + }) + .setProtectedHeader({ alg: "ES256", kid: roots.registry.kid }) + .setIssuedAt(now) + .setExpirationTime("15m") + .sign(roots.registry.privateKey); + + const grantedDomains = spec.grantedDomains ?? spec.requestedDomains; + const accreditationJws = await new SignJWT({ + decision: spec.decision ?? "granted", + level: spec.level, + domains: grantedDomains, + platformName: spec.platformName, + platformVersion: spec.version, + reviewedBy: spec.reviewedByEName ?? roots.wallet.ename, + }) + .setProtectedHeader({ alg: "ES256", kid: roots.association.kid }) + .setSubject(spec.platformEname) + .setJti(randomUUID()) + .setIssuedAt(now) + .sign(roots.association.privateKey); + + const evidence: DeploymentEvidence = { + deploymentEname, + deploymentName: spec.deploymentName, + environment: spec.environment, + deployerEname: roots.wallet.ename, + platformEname: spec.platformEname, + versionEname, + version: spec.version, + releaseTag: spec.releaseTag, + commitSha: spec.commitSha, + publicKey: key.publicKey, + deploymentKeyDocument, + softwareVersionDocument, + accreditationJws, + issuerJwksUri: spec.issuerJwksUri, + submissionProof: { + statement, + payload, + signature: await signP256(roots.wallet.privateKey, payload), + publicKey: roots.wallet.publicKey, + keyBindingCertificate, + verifiedAt: timestamp, + }, + }; + + return { identity: { evidence, privateKey: key.privateKey }, signedPayload }; +} diff --git a/packages/auth/src/platform/types.ts b/packages/auth/src/platform/types.ts new file mode 100644 index 000000000..2e3ed68e1 --- /dev/null +++ b/packages/auth/src/platform/types.ts @@ -0,0 +1,122 @@ +/** The shapes a deployment presents and a verifier resolves. */ + +export const CERTIFICATION_LEVELS = ["L0", "L1", "L2", "L3", "L4", "L5"] as const; +export type CertificationLevel = (typeof CERTIFICATION_LEVELS)[number]; + +export function levelRank(level: CertificationLevel): number { + return CERTIFICATION_LEVELS.indexOf(level); +} + +export interface BindingDocumentSignature { + signer: string; + signature: string; + timestamp: string; + scope?: "document" | "bundle"; + signedPayload?: string; +} + +export interface BindingDocument { + subject: string; + type: string; + data: Record; + signatures: BindingDocumentSignature[]; +} + +/** The release statement a platform signed when it applied to the association. */ +export interface SubmissionStatement { + type: string; + schemaVersion: number; + repositoryId: number; + repository: string; + platformEName: string; + platformName: string; + releaseTag: string; + version: string; + manifestCommitId: string; + domains: string[]; + signerEName: string; + issuedAt: string; + nonce: string; + [key: string]: unknown; +} + +export interface SubmissionProof { + statement: SubmissionStatement; + payload: string; + signature: string; + publicKey: string; + keyBindingCertificate: string; + verifiedAt: string; +} + +/** What a deployment sends. Everything here is public; none of it is a secret. */ +export interface DeploymentEvidence { + deploymentEname: string; + deploymentName: string; + environment: string; + deployerEname: string; + platformEname: string; + versionEname: string; + version: string; + releaseTag: string; + commitSha: string; + /** The deployment's own public key — the half of the pair it proves possession of. */ + publicKey: string; + deploymentKeyDocument: BindingDocument; + softwareVersionDocument: BindingDocument; + /** Compact ES256 JWS issued by the association over the certification decision. */ + accreditationJws: string; + issuerJwksUri: string; + submissionProof: SubmissionProof; +} + +export interface HandshakeChallenge { + nonce: string; + audience: string; + issuedAt: string; + expiresAt: string; +} + +export interface HandshakeResponse { + challenge: HandshakeChallenge; + evidence: DeploymentEvidence; + /** Signature by the deployment key over the canonical challenge payload. */ + signature: string; +} + +export type LinkId = + | "possession" + | "deployment-authorised" + | "bundle-integrity" + | "version-identity" + | "release-authorship" + | "accreditation"; + +export interface LinkResult { + id: LinkId; + title: string; + /** What this link proves when it holds — shown to a human reading the trace. */ + proves: string; + ok: boolean; + detail: string; +} + +export interface PlatformClaim { + platformEname: string; + platformName: string; + deploymentEname: string; + version: string; + level: CertificationLevel; + /** Domains the association certified, intersected with what the release asked for. */ + domains: string[]; + deployerEname: string; + reviewedByEName: string; +} + +export interface ChainResult { + ok: boolean; + links: LinkResult[]; + claim: PlatformClaim | null; + /** Set when the chain fails: the first link that did not hold. */ + failedAt: LinkId | null; +} diff --git a/platforms/registry/api/REGISTRY_PROTOCOL.md b/platforms/registry/api/REGISTRY_PROTOCOL.md index 0ec2c3699..dd0e5e7f1 100644 --- a/platforms/registry/api/REGISTRY_PROTOCOL.md +++ b/platforms/registry/api/REGISTRY_PROTOCOL.md @@ -219,6 +219,27 @@ Authorization: Bearer } ``` +### 5.1 Managed PlatformProfile Migration + +The management API is service-to-service only and requires +`Authorization: Bearer `. It does not expose the submitted legacy token in +responses or logs. + +- `POST /platforms/migrations/inspect-token` verifies a legacy platform JWT and returns its SHA-256 + fingerprint. +- `POST /platforms/migrations/activate` atomically binds an eName and its original PlatformProfile + envelope ID to one manager, records the supplied legacy-token fingerprint as revoked, and returns a + short-lived manager-scoped token. Repeating the identical transfer is idempotent; a competing + transfer returns `409`. +- `POST /platforms/management/token` issues a new short-lived token only to the recorded manager. +- `POST /platforms/management/authorize-profile-write` is called by eVault before a PlatformProfile + write. Unmanaged eNames retain legacy behavior. Managed profiles accept only their active manager + token and original envelope ID. + +The write restriction is scoped to User-profile ontology +`550e8400-e29b-41d4-a716-446655440000`. PPA accreditation envelopes and unrelated eVault records are +not management writes and retain their existing authorization paths. + ### 6. Platform Discovery Protocol **Method**: `GET /platforms` diff --git a/platforms/registry/api/src/config/database.ts b/platforms/registry/api/src/config/database.ts index 250f8431e..b260c909f 100644 --- a/platforms/registry/api/src/config/database.ts +++ b/platforms/registry/api/src/config/database.ts @@ -1,6 +1,7 @@ import { DataSource } from "typeorm" import { Vault } from "../entities/Vault" import { SoftwareVersion } from "../entities/SoftwareVersion" +import { PlatformManagement } from "../entities/PlatformManagement" // Import Verification entity from evault-core if available (shared database) import * as dotenv from "dotenv" import { join } from "path" @@ -13,7 +14,7 @@ export const AppDataSource = new DataSource({ url: process.env.REGISTRY_DATABASE_URL || "postgresql://postgres:postgres@localhost:5432/registry", synchronize: false, logging: process.env.DB_LOGGING === "true", - entities: [Vault, SoftwareVersion], + entities: [Vault, SoftwareVersion, PlatformManagement], // Verification entity will be handled by evault-core provisioning service migrations: [join(__dirname, "../migrations/*.{ts,js}")], migrationsTableName: "migrations", diff --git a/platforms/registry/api/src/entities/PlatformManagement.ts b/platforms/registry/api/src/entities/PlatformManagement.ts new file mode 100644 index 000000000..94bd1aa66 --- /dev/null +++ b/platforms/registry/api/src/entities/PlatformManagement.ts @@ -0,0 +1,22 @@ +import { Column, CreateDateColumn, Entity, PrimaryColumn, UpdateDateColumn } from "typeorm"; + +@Entity() +export class PlatformManagement { + @PrimaryColumn() + ename!: string; + + @Column() + manager!: string; + + @Column() + profileEnvelopeId!: string; + + @Column({ type: "varchar", length: 64 }) + revokedTokenFingerprint!: string; + + @CreateDateColumn({ type: "timestamptz" }) + createdAt!: Date; + + @UpdateDateColumn({ type: "timestamptz" }) + updatedAt!: Date; +} diff --git a/platforms/registry/api/src/index.ts b/platforms/registry/api/src/index.ts index e367e6fa3..151a166ba 100644 --- a/platforms/registry/api/src/index.ts +++ b/platforms/registry/api/src/index.ts @@ -7,6 +7,7 @@ import { generateEntropy, generatePlatformToken, generateKeyBindingCertificate, import { UriResolutionService } from "./services/UriResolutionService"; import { VaultService } from "./services/VaultService"; import { SoftwareVersionService, SoftwareVersionConflictError, softwareVersionEName } from "./services/SoftwareVersionService"; +import { PlatformManagementService, PlatformManagementConflictError } from "./services/PlatformManagementService"; import fs from "node:fs"; @@ -56,6 +57,7 @@ const initializeDatabase = async () => { // Initialize VaultService const vaultService = new VaultService(AppDataSource.getRepository("Vault")); const softwareVersionService = new SoftwareVersionService(AppDataSource.getRepository("SoftwareVersion")); +const platformManagementService = new PlatformManagementService(AppDataSource.getRepository("PlatformManagement")); // Initialize UriResolutionService (simplified for multi-tenant architecture) const uriResolutionService = new UriResolutionService(); @@ -188,6 +190,61 @@ server.post("/platforms/certification", async (request, reply) => { } }); +server.post( + "/platforms/migrations/inspect-token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { token } = request.body as { token?: string }; + if (!token) return reply.status(400).send({ error: "token is required" }); + return await platformManagementService.inspectLegacyToken(token); + } catch (error) { + return reply.status(401).send({ error: error instanceof Error ? error.message : "Invalid platform token" }); + } + }, +); + +server.post( + "/platforms/migrations/activate", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const input = request.body as { ename?: string; manager?: string; profileEnvelopeId?: string; legacyToken?: string }; + if (!input.ename || !input.manager || !input.profileEnvelopeId || !input.legacyToken) { + return reply.status(400).send({ error: "ename, manager, profileEnvelopeId, and legacyToken are required" }); + } + return await platformManagementService.transfer(input as Required); + } catch (error) { + if (error instanceof PlatformManagementConflictError) return reply.status(409).send({ error: error.message }); + return reply.status(401).send({ error: error instanceof Error ? error.message : "Migration activation failed" }); + } + }, +); + +server.post( + "/platforms/management/token", + { preHandler: checkSharedSecret }, + async (request, reply) => { + try { + const { ename, manager } = request.body as { ename?: string; manager?: string }; + if (!ename || !manager) return reply.status(400).send({ error: "ename and manager are required" }); + return { token: await platformManagementService.managerToken(ename, manager) }; + } catch (error) { + return reply.status(403).send({ error: error instanceof Error ? error.message : "Manager token denied" }); + } + }, +); + +server.post( + "/platforms/management/authorize-profile-write", + { preHandler: checkSharedSecret }, + async (request, reply) => { + const input = request.body as { ename?: string; ontology?: string; envelopeId?: string; token?: string }; + if (!input.ename || !input.ontology) return reply.status(400).send({ error: "ename and ontology are required" }); + return platformManagementService.authorizeProfileWrite(input as Required> & typeof input); + }, +); + // Generate key binding certificate (JWT binding ename and publicKey) server.post( "/key-binding-certificate", diff --git a/platforms/registry/api/src/jwt.ts b/platforms/registry/api/src/jwt.ts index bcf3c7caa..5a1db8fa2 100644 --- a/platforms/registry/api/src/jwt.ts +++ b/platforms/registry/api/src/jwt.ts @@ -66,20 +66,50 @@ export async function generatePlatformToken(platform: string): Promise { return token; } -export async function verifyPlatformToken(token: string): Promise { +export async function generateManagedPlatformToken(ename: string, manager: string): Promise { + await initializeKeys(); + return new SignJWT({ + platform: manager, + kind: "platform-manager", + managedEname: ename, + manager, + }) + .setProtectedHeader({ alg: "ES256", kid: "entropy-key-1" }) + .setJti(globalThis.crypto.randomUUID()) + .setIssuedAt() + .setExpirationTime("1h") + .sign(privateKey); +} + +export type PlatformTokenClaims = { + platform: string; + kind?: string; + managedEname?: string; + manager?: string; +}; + +export async function verifyPlatformTokenClaims(token: string): Promise { await initializeKeys(); try { const { payload } = await import("jose").then(({ jwtVerify }) => jwtVerify(token, publicKey, { algorithms: ["ES256"] }) ); - return typeof payload.platform === "string" && payload.platform.trim() - ? payload.platform - : null; + if (typeof payload.platform !== "string" || !payload.platform.trim()) return null; + return { + platform: payload.platform, + ...(typeof payload.kind === "string" && { kind: payload.kind }), + ...(typeof payload.managedEname === "string" && { managedEname: payload.managedEname }), + ...(typeof payload.manager === "string" && { manager: payload.manager }), + }; } catch { return null; } } +export async function verifyPlatformToken(token: string): Promise { + return (await verifyPlatformTokenClaims(token))?.platform ?? null; +} + // Generate and sign a JWT binding ename and publicKey together export async function generateKeyBindingCertificate( ename: string, diff --git a/platforms/registry/api/src/migrations/1788090000000-platform-management.ts b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts new file mode 100644 index 000000000..70eee7888 --- /dev/null +++ b/platforms/registry/api/src/migrations/1788090000000-platform-management.ts @@ -0,0 +1,21 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class PlatformManagement1788090000000 implements MigrationInterface { + name = "PlatformManagement1788090000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "platform_management" ( + "ename" character varying NOT NULL, + "manager" character varying NOT NULL, + "profileEnvelopeId" character varying NOT NULL, + "revokedTokenFingerprint" character varying(64) NOT NULL, + "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), + CONSTRAINT "PK_platform_management_ename" PRIMARY KEY ("ename") + )`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "platform_management"`); + } +} diff --git a/platforms/registry/api/src/services/PlatformManagementService.spec.ts b/platforms/registry/api/src/services/PlatformManagementService.spec.ts new file mode 100644 index 000000000..a7f019eef --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.spec.ts @@ -0,0 +1,53 @@ +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; +import { PlatformManagementConflictError, PlatformManagementService, tokenFingerprint } from "./PlatformManagementService"; + +jest.mock("../jwt", () => ({ + generateManagedPlatformToken: jest.fn(async () => "manager-token"), + verifyPlatformTokenClaims: jest.fn(), +})); + +describe("PlatformManagementService", () => { + const records = new Map(); + const repository = { + findOneBy: jest.fn(async ({ ename }: { ename: string }) => records.get(ename) ?? null), + create: jest.fn((input: PlatformManagement) => input), + save: jest.fn(async (input: PlatformManagement) => { + records.set(input.ename, input); + return input; + }), + } as unknown as Repository; + const service = new PlatformManagementService(repository); + + beforeEach(() => { + records.clear(); + jest.clearAllMocks(); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "legacy-publisher" }); + }); + + it("activates one idempotent management transfer and revokes the supplied token", async () => { + const input = { ename: "@platform", manager: "https://gitw3.example", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }; + const first = await service.transfer(input); + const repeated = await service.transfer(input); + + expect(first.management.revokedTokenFingerprint).toBe(tokenFingerprint("old-secret")); + expect(repeated.management).toEqual(first.management); + expect(generateManagedPlatformToken).toHaveBeenCalledTimes(2); + }); + + it("rejects a competing transfer", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + await expect(service.transfer({ ename: "@platform", manager: "manager-b", profileEnvelopeId: "profile-1", legacyToken: "old-secret" })) + .rejects.toBeInstanceOf(PlatformManagementConflictError); + }); + + it("allows only the active manager to write the managed profile envelope", async () => { + await service.transfer({ ename: "@platform", manager: "manager-a", profileEnvelopeId: "profile-1", legacyToken: "old-secret" }); + + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "other" })).toEqual({ managed: false, allowed: true }); + expect((await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "old-secret" })).allowed).toBe(false); + jest.mocked(verifyPlatformTokenClaims).mockResolvedValue({ platform: "manager-a", kind: "platform-manager", managedEname: "@platform", manager: "manager-a" }); + expect(await service.authorizeProfileWrite({ ename: "@platform", ontology: "550e8400-e29b-41d4-a716-446655440000", envelopeId: "profile-1", token: "new-secret" })).toEqual({ managed: true, allowed: true }); + }); +}); diff --git a/platforms/registry/api/src/services/PlatformManagementService.ts b/platforms/registry/api/src/services/PlatformManagementService.ts new file mode 100644 index 000000000..5649789cf --- /dev/null +++ b/platforms/registry/api/src/services/PlatformManagementService.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import type { Repository } from "typeorm"; +import type { PlatformManagement } from "../entities/PlatformManagement"; +import { generateManagedPlatformToken, verifyPlatformTokenClaims } from "../jwt"; + +export const PLATFORM_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +export class PlatformManagementConflictError extends Error {} + +export function tokenFingerprint(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +export class PlatformManagementService { + constructor(private readonly repository: Repository) {} + + async inspectLegacyToken(token: string): Promise<{ platform: string; fingerprint: string }> { + const claims = await verifyPlatformTokenClaims(token); + if (!claims || claims.kind === "platform-manager") { + throw new Error("A valid legacy platform token is required"); + } + return { platform: claims.platform, fingerprint: tokenFingerprint(token) }; + } + + async find(ename: string): Promise { + return this.repository.findOneBy({ ename }); + } + + async transfer(input: { + ename: string; + manager: string; + profileEnvelopeId: string; + legacyToken: string; + }): Promise<{ management: PlatformManagement; token: string }> { + const inspected = await this.inspectLegacyToken(input.legacyToken); + const existing = await this.find(input.ename); + const fingerprint = inspected.fingerprint; + if (existing) { + if ( + existing.manager !== input.manager || + existing.profileEnvelopeId !== input.profileEnvelopeId || + existing.revokedTokenFingerprint !== fingerprint + ) { + throw new PlatformManagementConflictError("This platform is already managed by another migration"); + } + return { management: existing, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + const management = await this.repository.save( + this.repository.create({ + ename: input.ename, + manager: input.manager, + profileEnvelopeId: input.profileEnvelopeId, + revokedTokenFingerprint: fingerprint, + }), + ); + return { management, token: await generateManagedPlatformToken(input.ename, input.manager) }; + } + + async managerToken(ename: string, manager: string): Promise { + const management = await this.find(ename); + if (!management || management.manager !== manager) { + throw new Error("The requested manager does not control this platform"); + } + return generateManagedPlatformToken(ename, manager); + } + + async authorizeProfileWrite(input: { + ename: string; + ontology: string; + envelopeId?: string; + token?: string; + }): Promise<{ managed: boolean; allowed: boolean; reason?: string }> { + if (input.ontology !== PLATFORM_PROFILE_ONTOLOGY) { + return { managed: false, allowed: true }; + } + const management = await this.find(input.ename); + if (!management) return { managed: false, allowed: true }; + if (input.envelopeId && input.envelopeId !== management.profileEnvelopeId) { + return { managed: true, allowed: false, reason: "The managed platform profile has a different envelope ID" }; + } + if (!input.token) { + return { managed: true, allowed: false, reason: "A platform manager token is required" }; + } + if (tokenFingerprint(input.token) === management.revokedTokenFingerprint) { + return { managed: true, allowed: false, reason: "The legacy platform token was revoked during migration" }; + } + const claims = await verifyPlatformTokenClaims(input.token); + const allowed = !!claims && claims.kind === "platform-manager" && claims.managedEname === input.ename && claims.manager === management.manager; + return { managed: true, allowed, ...(!allowed && { reason: "The token is not the active platform manager" }) }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c38cb2516..c7e15de67 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -765,6 +765,9 @@ importers: packages/auth: dependencies: + jose: + specifier: ^5.2.2 + version: 5.10.0 jsonwebtoken: specifier: ^9.0.2 version: 9.0.3 @@ -787,6 +790,9 @@ importers: typescript: specifier: ~5.6.2 version: 5.6.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) packages/eslint-config: devDependencies: @@ -3272,7 +3278,7 @@ importers: version: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) draft-js: specifier: ^0.11.7 - version: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: specifier: ^0.561.0 version: 0.561.0(react@18.3.1) @@ -3293,7 +3299,7 @@ importers: version: 18.3.1(react@18.3.1) react-draft-wysiwyg: specifier: ^1.15.0 - version: 1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-hook-form: specifier: ^7.55.0 version: 7.71.2(react@18.3.1) @@ -4081,6 +4087,61 @@ importers: specifier: ^3.0.2 version: 3.1.14 + services/pp-auth-demo: + dependencies: + '@metastate-foundation/auth': + specifier: workspace:* + version: link:../../packages/auth + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + graphql-request: + specifier: ^7.3.1 + version: 7.4.0(graphql@16.13.1) + jose: + specifier: ^5.2.2 + version: 5.10.0 + signature-validator: + specifier: workspace:* + version: link:../../infrastructure/signature-validator + svelte-qrcode: + specifier: ^1.0.1 + version: 1.0.1 + devDependencies: + '@sveltejs/adapter-node': + specifier: ^5.2.12 + version: 5.5.4(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.11)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))) + '@sveltejs/kit': + specifier: ^2.16.0 + version: 2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.53.11)(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.53.11)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.1(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + svelte: + specifier: ^5.0.0 + version: 5.53.11 + svelte-check: + specifier: ^4.0.0 + version: 4.4.5(picomatch@4.0.3)(svelte@5.53.11)(typescript@5.9.3) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.1 + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.2.6 + version: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + services/ppa: dependencies: axios: @@ -4135,6 +4196,9 @@ importers: vite: specifier: ^6.2.6 version: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) services/search-engine: dependencies: @@ -30181,6 +30245,26 @@ snapshots: - utf-8-validate - vite + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': + dependencies: + '@testing-library/dom': 10.4.1 + '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/utils': 3.2.4 + magic-string: 0.30.21 + sirv: 3.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + ws: 8.19.0(bufferutil@4.1.0) + optionalDependencies: + playwright: 1.58.2 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + optional: true + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30266,6 +30350,14 @@ snapshots: optionalDependencies: vite: 5.4.21(@types/node@24.12.0)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0) + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -30282,6 +30374,15 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@24.12.0)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + optional: true + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 3.2.4 @@ -32753,9 +32854,9 @@ snapshots: dotenv@17.3.1: {} - draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - fbjs: 2.0.0 + fbjs: 2.0.0(encoding@0.1.13) immutable: 3.7.6 object-assign: 4.1.1 react: 18.3.1 @@ -32763,9 +32864,9 @@ snapshots: transitivePeerDependencies: - encoding - draftjs-utils@0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + draftjs-utils@0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 drizzle-kit@0.31.9: @@ -33216,8 +33317,8 @@ snapshots: '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@2.6.1)) @@ -33280,6 +33381,21 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3(supports-color@5.5.0) + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.13.6 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.15 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 @@ -33322,6 +33438,17 @@ snapshots: - supports-color eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -33361,7 +33488,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -33390,6 +33517,35 @@ snapshots: - eslint-import-resolver-webpack - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.2 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 5.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.8.2) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-jsx-a11y@6.10.2(eslint@8.57.1): dependencies: aria-query: 5.3.2 @@ -34112,7 +34268,7 @@ snapshots: fbjs-css-vars@1.0.2: {} - fbjs@2.0.0: + fbjs@2.0.0(encoding@0.1.13): dependencies: core-js: 3.48.0 cross-fetch: 3.2.0(encoding@0.1.13) @@ -35008,9 +35164,9 @@ snapshots: html-tags@3.3.1: {} - html-to-draftjs@1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): + html-to-draftjs@1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5): dependencies: - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) immutable: 5.1.5 html-url-attributes@3.0.1: {} @@ -39382,12 +39538,12 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-draft-wysiwyg@1.15.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-draft-wysiwyg@1.15.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: classnames: 2.5.1 - draft-js: 0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - draftjs-utils: 0.10.2(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) - html-to-draftjs: 1.5.0(draft-js@0.11.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + draft-js: 0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + draftjs-utils: 0.10.2(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) + html-to-draftjs: 1.5.0(draft-js@0.11.7(encoding@0.1.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(immutable@5.1.5) immutable: 5.1.5 linkify-it: 2.2.0 prop-types: 15.8.1 @@ -42234,6 +42390,27 @@ snapshots: - supports-color - terser + vite-node@3.2.4(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@5.5.0) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vite-node@3.2.4(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: cac: 6.7.14 @@ -42362,6 +42539,25 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 + vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 20.19.26 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + sass: 1.98.0 + terser: 5.46.0 + tsx: 4.21.0 + yaml: 2.8.2 + optional: true + vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.4 @@ -42525,6 +42721,50 @@ snapshots: - supports-color - terser + vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@5.5.0) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + vite-node: 3.2.4(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.12 + '@types/node': 20.19.26 + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(vite@7.3.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@3.2.4) + jsdom: 19.0.0(bufferutil@4.1.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.15)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 diff --git a/services/ontology/schemas/accessPolicy.json b/services/ontology/schemas/accessPolicy.json new file mode 100644 index 000000000..7141bce3e --- /dev/null +++ b/services/ontology/schemas/accessPolicy.json @@ -0,0 +1,72 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "c7a41f6d-95b8-4e2a-9c33-8f0d1b6e4a72", + "title": "Access Policy", + "domain": "governance", + "type": "object", + "description": "The terms an eVault owner sets for platforms that want to reach their data. Certification says what a platform was found to be; this says what the owner will deal with. It is a signed statement rather than a stored setting, so it travels with the owner and can be checked by anyone — the eVault enforcing it, a platform deciding whether it is worth asking, or the owner auditing what they agreed to. The newest statement for a subject is the one in force. It can only narrow what a certificate grants, never widen it: a platform certified for social data cannot reach finance data because an owner permitted finance.", + "properties": { + "subject": { + "type": "string", + "pattern": "^@[^\\s]+$", + "description": "eName of the vault owner these terms bind. The signature must be theirs: a policy signed by anyone else is someone setting terms on a vault that is not theirs." + }, + "minimumLevel": { + "type": "string", + "enum": ["L0", "L1", "L2", "L3", "L4", "L5"], + "description": "The weakest certification level the owner will deal with. A platform certified below this is refused whatever domains its certificate names." + }, + "reputationEngine": { + "type": "string", + "description": "eName or URL of the reputation service whose scores the owner accepts. Empty when the owner does not weigh reputation, in which case no score is consulted and none can refuse a platform." + }, + "minimumReputation": { + "type": ["number", "null"], + "description": "Score that engine must report for the platform, on the engine's own scale. Null when the owner sets no threshold." + }, + "allowedDomains": { + "type": ["array", "null"], + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "description": "Domains the owner permits. Null means whatever the certificate grants, which is the ordinary case; a list narrows that further." + }, + "deniedDomains": { + "type": "array", + "uniqueItems": true, + "items": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "description": "Domains refused outright, overriding both the certificate and the allow list." + }, + "payload": { + "type": "string", + "minLength": 1, + "description": "What was signed: the prefix `w3ds:access-policy:v1:` followed by the base64url SHA-256 of the canonical statement." + }, + "signature": { + "type": "string", + "minLength": 1, + "description": "The owner's wallet signature over `payload`." + }, + "issuedAt": { + "type": "string", + "format": "date-time" + }, + "nonce": { + "type": "string", + "minLength": 1, + "description": "Makes each statement distinct, so re-signing the same terms produces a new record rather than a duplicate." + } + }, + "required": [ + "subject", + "minimumLevel", + "reputationEngine", + "minimumReputation", + "allowedDomains", + "deniedDomains", + "payload", + "signature", + "issuedAt", + "nonce" + ], + "additionalProperties": false +} diff --git a/services/ontology/schemas/certification-level.json b/services/ontology/schemas/certification-level.json new file mode 100644 index 000000000..49cfd755b --- /dev/null +++ b/services/ontology/schemas/certification-level.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "2e75bd9c-8321-43c5-99f2-a7a2bfb56e11", + "title": "Certification Level", + "domain": "governance", + "type": "object", + "description": "A PPA application certification level. Levels are cumulative — each includes the requirements of those below it — and the level is an aggregate indicator over independently recorded assurance dimensions. Certification applies to a specific application release, not to an application indefinitely. This schema is the published list; the permitted values are the oneOf under `id`.", + "properties": { + "id": { + "type": "string", + "description": "Stable level id, recorded on every accreditation.", + "oneOf": [ + { + "const": "L0", + "title": "Level 0 — W3DS-compatible experimental", + "description": "This release can technically participate in W3DS and a minimally identified person stands behind it. No meaningful software-assurance review has been performed; in practice almost no production eVault should admit such an application." + }, + { + "const": "L1", + "title": "Level 1 — identified responsible person, basic functional review", + "description": "The first real trust level. PPA knows who is responsible and has seen that the release broadly does what it claims. Source is available and may be scanned automatically, but no manual code review is required." + }, + { + "const": "L2", + "title": "Level 2 — source-code review and direct assessment", + "description": "Manual code review becomes a requirement for every certified release. Developer and deployer may differ but their roles are recorded, and PPA interviews the owner, responsible developer and deployer." + }, + { + "const": "L3", + "title": "Level 3 — proven development history and reputation", + "description": "Requires IAL4 for all key accountable actors, a traceable contribution history, signed professional references, and demonstrated prior operational experience." + }, + { + "const": "L4", + "title": "Level 4 — established operational reputation and independent review", + "description": "Substantial real-world use and a much larger body of authenticated feedback, plus at least one signed review by a recognised independent professional or organisation." + }, + { + "const": "L5", + "title": "Level 5 — high-assurance industrial application", + "description": "For systems where compromise or failure may have serious consequences: extensive operational evidence, multiple independent reviews, strong deployment provenance and rigorous key control." + } + ] + }, + "label": { + "type": "string", + "description": "Human-readable name." + }, + "description": { + "type": "string", + "description": "What the level means." + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false +} diff --git a/services/ontology/schemas/identity-assurance-level.json b/services/ontology/schemas/identity-assurance-level.json new file mode 100644 index 000000000..10dd6937a --- /dev/null +++ b/services/ontology/schemas/identity-assurance-level.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b5e7d4f4-3c14-4831-8b97-ce15b5a70b30", + "title": "Identity Assurance Level", + "domain": "governance", + "type": "object", + "description": "The identity confidence held for an accountable human actor behind a certified release. Every certification level sets a minimum: L0 requires IAL2, L1–L2 require IAL3, and L3–L5 require IAL4. A wholly anonymous responsible party cannot hold a certified release. This schema is the published list; the permitted values are the oneOf under `id`.", + "properties": { + "id": { + "type": "string", + "description": "Stable identity assurance level id.", + "oneOf": [ + { + "const": "IAL1", + "title": "IAL1 — anonymous", + "description": "No reliable identity information. This level is not sufficient for PPA application certification." + }, + { + "const": "IAL2", + "title": "IAL2 — attested", + "description": "The person’s name and identity are confirmed by at least one already-identified person." + }, + { + "const": "IAL3", + "title": "IAL3 — passport-verified", + "description": "The person is verified against a passport or an equivalent high-confidence eID." + }, + { + "const": "IAL4", + "title": "IAL4 — passport and independent attestation", + "description": "IAL3 plus confirmation of the person’s identity by at least three passport-identified people." + } + ] + }, + "label": { + "type": "string", + "description": "Human-readable name." + }, + "description": { + "type": "string", + "description": "What the level means." + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false +} diff --git a/services/ontology/schemas/platformAccreditation.json b/services/ontology/schemas/platformAccreditation.json index 6cdf2597f..3cab42e67 100644 --- a/services/ontology/schemas/platformAccreditation.json +++ b/services/ontology/schemas/platformAccreditation.json @@ -39,6 +39,7 @@ "null" ], "enum": [ + "L0", "L1", "L2", "L3", @@ -46,7 +47,33 @@ "L5", null ], - "description": "Access level granted; null when decision is 'denied'" + "description": "Access level granted; null when denied. Levels are cumulative and defined by the certification framework." + }, + "computedLevel": { + "type": [ + "string", + "null" + ], + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "L5", + null + ], + "description": "Level the assessment implied before any reviewer override, so a divergence between judgement and evidence is visible on the certificate itself." + }, + "minimumIal": { + "type": "string", + "enum": [ + "IAL1", + "IAL2", + "IAL3", + "IAL4" + ], + "description": "Weakest identity assurance across the accountable actors at decision time." }, "domains": { "type": "array", @@ -90,6 +117,15 @@ "type": "string", "description": "MetaEnvelope id of the PlatformProfile submission this decision reviewed" }, + "frameworkVersion": { + "type": "string", + "minLength": 1, + "description": "Version of the certification framework applied." + }, + "assessmentEnvelopeId": { + "type": "string", + "description": "MetaEnvelope id of the PlatformAssessment holding the findings behind this decision." + }, "supersedes": { "type": [ "string", diff --git a/services/ontology/schemas/platformAssessment.json b/services/ontology/schemas/platformAssessment.json new file mode 100644 index 000000000..ee3713d04 --- /dev/null +++ b/services/ontology/schemas/platformAssessment.json @@ -0,0 +1,206 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "schemaId": "b0c8cfad-2872-4fb7-9d99-278f257bb922", + "title": "Platform Assessment", + "domain": "governance", + "type": "object", + "description": "The findings behind a PPA certification decision for one platform release. The certification level is an aggregate, but the framework requires every assurance dimension to remain separately recorded, so this is the evidence a later eVault or reputation engine can inspect rather than relying on the headline level. Stored in the reviewed platform’s eVault with a public ACL, alongside the PlatformAccreditation that cites it.", + "properties": { + "assessmentId": { + "type": "string", + "minLength": 1, + "description": "Stable id for this assessment; cited by the accreditation." + }, + "platformEName": { + "type": "string", + "minLength": 1 + }, + "platformVersion": { + "type": "string", + "minLength": 1, + "description": "The release assessed. Assessment is per release, not per application." + }, + "frameworkVersion": { + "type": "string", + "minLength": 1, + "description": "Version of the certification framework applied. Its thresholds are policy parameters and change over time." + }, + "dimensions": { + "type": "array", + "description": "One entry per assurance dimension in the framework, recorded independently: a strong result in one does not erase a weakness in another.", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Dimension id from the framework." + }, + "answer": { + "type": "string", + "description": "The requirement text selected." + }, + "level": { + "type": "integer", + "minimum": -1, + "maximum": 5, + "description": "Highest certification level this answer satisfies; -1 blocks certification." + }, + "source": { + "type": "string", + "enum": [ + "derived", + "reviewer" + ], + "description": "Whether the app established this from evidence — the release proof, binding documents, attested deployments or signed eReputation references — or a reviewer judged it." + }, + "note": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "answer", + "level", + "source" + ], + "additionalProperties": false + } + }, + "actors": { + "type": "array", + "description": "Every accountable human actor and the identity assurance held for them.", + "items": { + "type": "object", + "properties": { + "ename": { + "type": "string", + "minLength": 1 + }, + "role": { + "type": "string", + "description": "author, releaseSigner or deployer." + }, + "ial": { + "type": "string", + "enum": [ + "IAL1", + "IAL2", + "IAL3", + "IAL4" + ] + }, + "idDocuments": { + "type": "integer", + "minimum": 0 + }, + "attestations": { + "type": "integer", + "minimum": 0, + "description": "Social-connection attestations counted." + }, + "verifiedAttesters": { + "type": "integer", + "minimum": 0, + "description": "Of those, how many attesters were themselves passport-verified." + }, + "overridden": { + "type": "boolean", + "description": "True when a reviewer set this IAL by hand rather than accepting the derivation." + }, + "note": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "ename", + "role", + "ial" + ], + "additionalProperties": false + } + }, + "minimumIal": { + "type": "string", + "enum": [ + "IAL1", + "IAL2", + "IAL3", + "IAL4" + ], + "description": "The weakest actor’s identity assurance, which is what gates the level." + }, + "computedLevel": { + "type": [ + "string", + "null" + ], + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "L5", + null + ], + "description": "Level implied by the weakest dimension and the identity floor. Null when no level is supportable." + }, + "limitingDimension": { + "type": [ + "string", + "null" + ], + "description": "Dimension id that held the computed level down." + }, + "awardedLevel": { + "type": [ + "string", + "null" + ], + "enum": [ + "L0", + "L1", + "L2", + "L3", + "L4", + "L5", + null + ], + "description": "What the reviewer actually awarded. Null on a denial." + }, + "overrideReason": { + "type": [ + "string", + "null" + ], + "description": "Required whenever awardedLevel differs from computedLevel, so a divergence is always explained." + }, + "reviewedByEName": { + "type": "string", + "minLength": 1 + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "assessmentId", + "platformEName", + "platformVersion", + "frameworkVersion", + "dimensions", + "actors", + "minimumIal", + "computedLevel", + "reviewedByEName", + "createdAt" + ], + "additionalProperties": false +} diff --git a/services/pp-auth-demo/package.json b/services/pp-auth-demo/package.json new file mode 100644 index 000000000..aaed2a76c --- /dev/null +++ b/services/pp-auth-demo/package.json @@ -0,0 +1,35 @@ +{ + "name": "pp-auth-demo", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev --host --port 4310 --strictPort", + "build": "vite build", + "preview": "vite preview --port 4310 --strictPort", + "prepare": "svelte-kit sync || echo ''", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest run" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^5.2.12", + "@sveltejs/kit": "^2.16.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "@tailwindcss/vite": "^4.0.0", + "@types/node": "^20.11.24", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^6.2.6", + "vitest": "^3.2.4" + }, + "dependencies": { + "@metastate-foundation/auth": "workspace:*", + "jose": "^5.2.2", + "svelte-qrcode": "^1.0.1", + "dotenv": "^16.4.5", + "graphql-request": "^7.3.1", + "signature-validator": "workspace:*" + } +} diff --git a/services/pp-auth-demo/src/app.css b/services/pp-auth-demo/src/app.css new file mode 100644 index 000000000..60b62eae1 --- /dev/null +++ b/services/pp-auth-demo/src/app.css @@ -0,0 +1,135 @@ +@import "tailwindcss"; + +/** + * Design tokens taken from w3alliance.net: a light, institutional palette on + * white, violet #8869ff as the single accent, deep navy #1d2636 for headings + * and #333 for body copy, Inter throughout, and generously rounded surfaces + * with soft shadows rather than hard borders. + */ +@theme { + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif; + + --color-canvas: #f6f5fb; + --color-surface: #ffffff; + --color-ink: #1d2636; + --color-body: #333333; + --color-muted: #6b7280; + --color-faint: #9aa1ad; + --color-line: #e9e7f2; + + --color-brand: #8869ff; + --color-brand-strong: #6f4dff; + --color-brand-tint: #ddd3ff; + --color-brand-wash: #f4f1ff; + --color-info: #0099ff; + + --color-positive: #0f9d68; + --color-positive-wash: #e7f6ef; + --color-caution: #b26a00; + --color-caution-wash: #fdf3e3; + --color-negative: #d1443c; + --color-negative-wash: #fdeceb; + + --radius-card: 1.5rem; + --radius-panel: 2rem; + + --shadow-soft: 0 4px 20px rgb(29 38 54 / 0.06); + --shadow-lift: 0 12px 32px rgb(29 38 54 / 0.10); +} + +html { + color-scheme: light; +} + +body { + background: var(--color-canvas); + color: var(--color-body); + -webkit-font-smoothing: antialiased; +} + +@layer components { + .card { + background: var(--color-surface); + border: 1px solid var(--color-line); + border-radius: var(--radius-card); + box-shadow: var(--shadow-soft); + } + + /* Section label above a heading — small, tracked, brand-coloured. */ + .eyebrow { + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-brand); + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + border-radius: 999px; + padding: 0.6875rem 1.375rem; + font-size: 0.875rem; + font-weight: 600; + transition: background-color 0.15s, border-color 0.15s, color 0.15s; + } + + .btn-primary { + background: var(--color-brand); + color: #fff; + } + .btn-primary:hover:not(:disabled) { + background: var(--color-brand-strong); + } + .btn-primary:disabled { + opacity: 0.55; + } + + .btn-quiet { + border: 1px solid var(--color-line); + background: var(--color-surface); + color: var(--color-ink); + } + .btn-quiet:hover { + border-color: var(--color-brand-tint); + color: var(--color-brand); + } + + .pill { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: 999px; + padding: 0.3125rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; + } + + .field { + width: 100%; + border: 1px solid var(--color-line); + border-radius: 1rem; + background: var(--color-surface); + padding: 0.75rem 1rem; + font-size: 0.875rem; + color: var(--color-body); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + } + .field:focus { + border-color: var(--color-brand); + box-shadow: 0 0 0 4px var(--color-brand-wash); + } + + /* Long opaque strings (eNames, JWS) that must not blow out the layout. */ + .mono-block { + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 0.75rem; + line-height: 1.5; + overflow-wrap: anywhere; + color: var(--color-muted); + } +} diff --git a/services/pp-auth-demo/src/app.d.ts b/services/pp-auth-demo/src/app.d.ts new file mode 100644 index 000000000..219a939a2 --- /dev/null +++ b/services/pp-auth-demo/src/app.d.ts @@ -0,0 +1,10 @@ +declare global { + namespace App { + interface Locals { + /** The signed-in eVault owner, or null when unauthenticated. */ + user: { ename: string } | null; + } + } +} + +export {}; diff --git a/services/pp-auth-demo/src/app.html b/services/pp-auth-demo/src/app.html new file mode 100644 index 000000000..37cbac765 --- /dev/null +++ b/services/pp-auth-demo/src/app.html @@ -0,0 +1,19 @@ + + + + + + + + + + PP Auth demonstrator + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/services/pp-auth-demo/src/hooks.server.ts b/services/pp-auth-demo/src/hooks.server.ts new file mode 100644 index 000000000..464bfd46b --- /dev/null +++ b/services/pp-auth-demo/src/hooks.server.ts @@ -0,0 +1,46 @@ +import { redirect, type Handle } from "@sveltejs/kit"; +import { COOKIE, read } from "$lib/server/token"; + +const PUBLIC_PATHS = new Set(["/login"]); + +function isPublic(pathname: string): boolean { + if (PUBLIC_PATHS.has(pathname)) return true; + if (pathname.startsWith("/api/auth")) return true; + if (pathname.startsWith("/api/sign")) return true; + // The verifier endpoints are for deployments, which have no session. + if (pathname.startsWith("/pp-auth/")) return true; + return false; +} + +/** + * The wallet posts its callback from a phone, cross-origin, so the callback + * routes need CORS — including the private-network preflight Chrome sends when + * a public page calls a LAN address. + */ +function cors(response: Response): Response { + response.headers.set("Access-Control-Allow-Origin", "*"); + response.headers.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + response.headers.set("Access-Control-Allow-Private-Network", "true"); + response.headers.set("Access-Control-Max-Age", "86400"); + return response; +} + +export const handle: Handle = async ({ event, resolve }) => { + event.locals.user = read(event.cookies.get(COOKIE)); + + if (event.request.method === "OPTIONS") { + return cors(new Response(null, { status: 204 })); + } + + const { pathname } = event.url; + if (!event.locals.user && !isPublic(pathname)) { + if (pathname.startsWith("/api/")) { + return cors(new Response("Unauthorized", { status: 401 })); + } + throw redirect(302, "/login"); + } + if (event.locals.user && pathname === "/login") throw redirect(302, "/platforms"); + + return cors(await resolve(event)); +}; diff --git a/services/pp-auth-demo/src/lib/ChainTrace.svelte b/services/pp-auth-demo/src/lib/ChainTrace.svelte new file mode 100644 index 000000000..19ce5b0d7 --- /dev/null +++ b/services/pp-auth-demo/src/lib/ChainTrace.svelte @@ -0,0 +1,35 @@ + + +{#if chain} +
    + {#each chain.links as link, index (link.id)} +
  1. + + {link.ok ? "✓" : index + 1} + +
    +

    {link.title}

    +

    + {link.ok ? `So we know ${link.proves}.` : link.detail} +

    +
    +
  2. + {/each} +
+{/if} diff --git a/services/pp-auth-demo/src/lib/DeploymentRow.svelte b/services/pp-auth-demo/src/lib/DeploymentRow.svelte new file mode 100644 index 000000000..0fa5c2710 --- /dev/null +++ b/services/pp-auth-demo/src/lib/DeploymentRow.svelte @@ -0,0 +1,92 @@ + + +
+
+
+

+ {deployment.name} + {deployment.environment} +

+

+ {deployment.releaseTag} · {deployment.commitSha.slice(0, 12)} +

+
+ +
+ +
+ +
+ + {#if checked && missing.length > 0} +

+ Cannot be checked yet — {missing.join("; ")}. +

+ {/if} + + {#if chain} +
+ +
+ + {#if chain.claim} +

+ Proved: {chain.claim.platformName} {chain.claim.version}, certified + {chain.claim.level} for {chain.claim.domains.join(", ") || "no domains"}. +

+ {:else if chain.failedAt === "possession" && !deployment.keyHeld} +

+ Everything that can be checked by reading has been checked. The one + thing left is whether whoever is calling actually holds this + deployment's key — enter it above and check again. +

+ {/if} + {/if} +
diff --git a/services/pp-auth-demo/src/lib/KeyEntry.svelte b/services/pp-auth-demo/src/lib/KeyEntry.svelte new file mode 100644 index 000000000..b454316ec --- /dev/null +++ b/services/pp-auth-demo/src/lib/KeyEntry.svelte @@ -0,0 +1,94 @@ + + +
+
+

+ {deployment.name} + {deployment.environment} +

+

+ {deployment.keyHeld + ? "Key supplied — this deployment can answer a challenge." + : "No key — its identity cannot be proved from here."} +

+
+
+ {#if deployment.keyHeld} + Can prove itself + + {:else} + + {/if} +
+ + {#if open && !deployment.keyHeld} +
+ +
+ +

+ Held in memory for this process only. Never written down, never logged. +

+
+ {#if error} +

{error}

+ {/if} +
+ {/if} +
diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte new file mode 100644 index 000000000..b2a1004e5 --- /dev/null +++ b/services/pp-auth-demo/src/lib/RequestTester.svelte @@ -0,0 +1,196 @@ + + +
+

Try a request

+ +
+ + + +
+ + {#if operation === "write"} + + {/if} + + {#if !held} +

+ This deployment has no key here, so it cannot prove who it is and the + request will stop at the handshake. Enter its key above first. +

+ {/if} + + + + {#if stage === "evidence"} +

+ Cannot be checked — {missing.join("; ")}. +

+ {:else if decision} +

+ {decision.reason} +

+ {:else if stage === "handshake" && chain} +

+ Refused before any permission was consulted — it could not prove what it is. + {chain.links.find((link) => !link.ok)?.detail} +

+ {/if} + + {#if decision?.allowed} +
+

+ {wrote ? "Written, and read back from your eVault" : "Pulled from your eVault"} +

+ {#if wrote} +

Stored a new {wrote.kind} record.

+ {/if} + {#if note} +

{note}

+ {/if} + {#if records && records.length > 0} +
    + {#each records as record (record.id)} +
  • +

    {record.kind}

    +

    {record.summary}

    +
  • + {/each} +
+ {:else if records} +

+ The read was permitted and went through — your eVault holds nothing + of this kind. +

+ {/if} +
+ {:else if decision} +

+ Nothing was fetched. The eVault was never asked. +

+ {/if} + + {#if chain} +
+ + What it proved + +
+ +
+
+ {/if} +
diff --git a/services/pp-auth-demo/src/lib/TermsForm.svelte b/services/pp-auth-demo/src/lib/TermsForm.svelte new file mode 100644 index 000000000..d950c6e3d --- /dev/null +++ b/services/pp-auth-demo/src/lib/TermsForm.svelte @@ -0,0 +1,183 @@ + + +
+
+

+ The least you will accept +

+
+ {#each LEVELS as level (level.id)} + + {/each} +
+
+ +
+

+ Whose reputation scores you trust +

+

{reputationEngine}

+

+ The only reputation service on the network today, so there is nothing to + choose. It is named in what you sign, so the record says which service you + accepted scores from. +

+
+ + {#if domains.length > 0} +
+

+ Things nobody gets, whatever their certificate says +

+
+ {#each domains as domain (domain.id)} + + {/each} +
+
+ {/if} + +
+ + {#if done} + Signed and published to your eVault. + {/if} + {#if error} + {error} + {/if} +
+ + {#if uri} +
+

Approve these terms in your wallet.

+
+ +
+
+ {/if} +
diff --git a/services/pp-auth-demo/src/lib/domains.ts b/services/pp-auth-demo/src/lib/domains.ts new file mode 100644 index 000000000..be12a3912 --- /dev/null +++ b/services/pp-auth-demo/src/lib/domains.ts @@ -0,0 +1,14 @@ +/** + * The domains this demonstration deals in. + * + * These four ids are taken from the published Domain vocabulary + * (services/ontology/schemas/domain.json), which is what every schema tags + * itself with and what a certificate grants. The full list is twenty; four is + * enough to show separation without turning the page into a wall of buttons. + */ +export const DOMAINS = [ + { id: "social", label: "Social" }, + { id: "communication", label: "Communication" }, + { id: "finance", label: "Finance" }, + { id: "health", label: "Health" }, +] as const; diff --git a/services/pp-auth-demo/src/lib/server/aaas.ts b/services/pp-auth-demo/src/lib/server/aaas.ts new file mode 100644 index 000000000..3eb443e56 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/aaas.ts @@ -0,0 +1,174 @@ +/** + * Everything this app knows about the network comes from Awareness-as-a-Service. + * + * Accreditations, deployment profiles and platform profiles each have their own + * ontology, so they can be asked for directly rather than scanned for. + */ + +import { awarenessApiKey, awarenessUrl } from "./env"; +import { + DEPLOYMENT_PROFILE_ONTOLOGY, + PLATFORM_ACCREDITATION_ONTOLOGY, + USER_ONTOLOGY, + type AccreditationRecord, + type DeploymentRecord, +} from "./ontology"; + +interface Packet { + id: string; + ontology: string; + w3id: string | null; + data: Record | null; + receivedAt: string; +} + +export function isConfigured(): boolean { + return Boolean(awarenessApiKey()); +} + +async function packets(params: Record): Promise { + if (!isConfigured()) return []; + const out: Packet[] = []; + let cursor: string | null = null; + do { + const query = new URLSearchParams({ limit: "500", ...params }); + if (cursor) query.set("cursor", cursor); + const res = await fetch(`${awarenessUrl().replace(/\/$/, "")}/api/packets?${query}`, { + headers: { Authorization: `Bearer ${awarenessApiKey()}` }, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + throw new Error(`AaaS /api/packets returned ${res.status}`); + } + const body = (await res.json()) as { + packets?: Packet[]; + hasMore?: boolean; + nextCursor?: string | null; + }; + out.push(...(body.packets ?? [])); + cursor = body.hasMore ? (body.nextCursor ?? null) : null; + } while (cursor); + return out; +} + +/** Short cache: these reads back every page and the data changes rarely. */ +const TTL_MS = 30_000; +const CACHE = Symbol.for("pp-auth-demo.aaas"); +const store = globalThis as typeof globalThis & { + [CACHE]?: Map; +}; +store[CACHE] ??= new Map(); + +async function cached(key: string, load: () => Promise): Promise { + const entry = store[CACHE]!.get(key); + if (entry && Date.now() - entry.at < TTL_MS) return entry.value as T; + const value = await load(); + store[CACHE]!.set(key, { at: Date.now(), value }); + return value; +} + +export function invalidate(): void { + store[CACHE]!.clear(); +} + +function str(value: unknown): string { + return typeof value === "string" ? value : ""; +} + +/** + * Every certification decision on the network, newest first. + * + * A decision covers one platform version, and a version can be refused and + * reapply, so several may exist for the same release. + */ +export async function accreditations(): Promise { + return cached("accreditations", async () => { + const found = await packets({ ontology: PLATFORM_ACCREDITATION_ONTOLOGY }); + return found + .map((packet) => packet.data) + .filter( + (data): data is AccreditationRecord => + Boolean(data) && + typeof data!.platformEName === "string" && + typeof data!.jws === "string", + ) + .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); + }); +} + +/** Every deployment published on the network. */ +export async function deployments(): Promise { + return cached("deployments", async () => { + const found = await packets({ ontology: DEPLOYMENT_PROFILE_ONTOLOGY }); + const byEname = new Map(); + for (const packet of found) { + const data = packet.data; + if (!data || !str(data.deploymentEname)) continue; + byEname.set(str(data.deploymentEname), data as DeploymentRecord); + } + return [...byEname.values()]; + }); +} + +export interface PlatformProfile { + ename: string; + platformName: string; + displayName: string; + description: string; + version: string; + logoUrl: string | null; + url: string; + /** Every release proof the platform retains, so an older deployment resolves. */ + proofs: Array>; +} + +/** One platform's own profile, read from its eVault. */ +export async function platformProfile(ename: string): Promise { + return cached(`profile:${ename}`, async () => { + const found = await packets({ evault: ename, ontology: USER_ONTOLOGY }); + const data = found + .map((packet) => packet.data) + .filter((d): d is Record => Boolean(d) && Boolean(str(d!.platformName))) + .at(-1); + if (!data) return null; + const proofs = [ + ...(Array.isArray(data.submissionHistory) ? data.submissionHistory : []), + data.submissionProof, + ].filter((proof) => proof && typeof proof === "object" && proof.statement); + return { + ename, + platformName: str(data.platformName), + displayName: str(data.displayName) || str(data.platformName), + description: str(data.description), + version: str(data.version), + logoUrl: str(data.logoUrl) || null, + url: str(data.url), + proofs, + }; + }); +} + +/** A person's profile, for showing who deployed something. */ +export async function personProfile( + ename: string, +): Promise<{ ename: string; displayName: string; avatarUrl: string | null }> { + return cached(`person:${ename}`, async () => { + const fallback = { ename, displayName: ename, avatarUrl: null }; + try { + const found = await packets({ evault: ename, ontology: USER_ONTOLOGY }); + const data = found + .map((packet) => packet.data) + .filter((d): d is Record => Boolean(d) && !str(d!.platformName)) + .at(-1); + if (!data) return fallback; + return { + ename, + displayName: + str(data.displayName) || str(data.name) || str(data.username) || ename, + avatarUrl: str(data.avatarUrl) || str(data.avatar) || null, + }; + } catch { + return fallback; + } + }); +} diff --git a/services/pp-auth-demo/src/lib/server/chain.ts b/services/pp-auth-demo/src/lib/server/chain.ts new file mode 100644 index 000000000..28c28e866 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/chain.ts @@ -0,0 +1,198 @@ +/** + * Assembles real evidence for a real deployment, and verifies it. + * + * Nothing here is manufactured. The deployment profile and the certificate come + * from the awareness network, the binding documents from the deployment's own + * eVault, and the release proof from the platform's profile. The only thing the + * verifier cannot obtain by reading is the deployment's private key, which is + * the point of the possession link. + */ + +import { + answerChallenge, + verifyDeploymentChain, + type ChainResult, + type DeploymentEvidence, + type HandshakeChallenge, +} from "@metastate-foundation/auth/platform"; +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 type { AccreditationRecord, DeploymentRecord } from "./ontology"; + +/** + * Wallet signatures are resolved through the registry, the same way every other + * service in the network checks one. + */ +async function verifyWalletSignature( + signer: string, + signature: string, + payload: string, +): Promise { + try { + const result = await verifySignature({ + eName: signer, + signature, + payload, + registryBaseUrl: registryUrl(), + }); + return result.valid === true; + } catch { + return false; + } +} + +export interface AssembledEvidence { + evidence: DeploymentEvidence | null; + /** What could not be found, in words, when evidence is incomplete. */ + missing: string[]; + accreditation: AccreditationRecord | null; + deployment: DeploymentRecord; +} + +/** The decision in force for one platform release: newest record wins. */ +export function accreditationFor( + records: AccreditationRecord[], + platformEname: string, + version: string, +): AccreditationRecord | null { + return ( + records.find( + (record) => + record.platformEName === platformEname && + record.platformVersion === version, + ) ?? null + ); +} + +export async function assemble( + deployment: DeploymentRecord, +): Promise { + const missing: string[] = []; + const [records, profile, docs] = await Promise.all([ + accreditations(), + platformProfile(deployment.platformEname), + bindingDocuments(deployment.deploymentEname), + ]); + + const accreditation = accreditationFor( + records, + deployment.platformEname, + deployment.version, + ); + if (!accreditation) { + missing.push(`no certification decision for version ${deployment.version}`); + } + + const keyDoc = docs.find((doc) => doc.type === "deployment_key"); + const versionDoc = docs.find((doc) => doc.type === "software_version"); + if (!keyDoc) missing.push("the deployment's key document is not readable"); + if (!versionDoc) missing.push("the deployment's release document is not readable"); + + // The platform profile carries its LATEST release proof, but a deployment + // may be running an older one, so match on the version actually deployed + // rather than taking whatever is current. + const proof = profile?.proofs.filter( + (entry) => entry?.statement?.version === deployment.version, + ).at(-1); + if (!proof) { + missing.push(`no signed release proof for version ${deployment.version}`); + } + + if (!accreditation || !keyDoc || !versionDoc || !proof) { + return { evidence: null, missing, accreditation, deployment }; + } + + return { + missing, + accreditation, + deployment, + evidence: { + deploymentEname: deployment.deploymentEname, + deploymentName: deployment.deploymentName, + environment: deployment.environment, + deployerEname: deployment.deployerEname, + platformEname: deployment.platformEname, + versionEname: deployment.versionEname, + version: deployment.version, + releaseTag: deployment.releaseTag, + commitSha: deployment.commitSha, + publicKey: deployment.publicKey, + deploymentKeyDocument: keyDoc as never, + softwareVersionDocument: versionDoc as never, + accreditationJws: accreditation.jws, + issuerJwksUri: accreditation.issuerJwksUri, + submissionProof: proof as never, + }, + }; +} + +export function challengeFor(audience: string): HandshakeChallenge { + const now = Date.now(); + return { + nonce: randomUUID(), + audience, + issuedAt: new Date(now).toISOString(), + expiresAt: new Date(now + 120_000).toISOString(), + }; +} + +/** + * Verifies a deployment's chain. + * + * When the operator has supplied that deployment's private key, the challenge + * is answered for real and all six links are checked. Without it the signature + * is one this app makes with a throwaway key: possession then fails, correctly, + * and the remaining five links are still checked against real evidence. + */ +export async function verify( + evidence: DeploymentEvidence, + audience: string, + privateKey: string | null, +): Promise<{ chain: ChainResult; possessionProven: boolean }> { + const challenge = challengeFor(audience); + const response = privateKey + ? await answerChallenge({ evidence, privateKey }, challenge) + : { challenge, evidence, signature: "" }; + + const chain = await verifyDeploymentChain(response, { + audience, + registryBaseUrl: registryUrl(), + verifyWalletSignature, + }); + + // With no key there was nothing to check, which is not the same as a check + // that failed. Saying "the signature did not verify" would suggest the + // deployment presented something wrong rather than that we never asked it. + if (!privateKey) { + const possession = chain.links.find((link) => link.id === "possession"); + if (possession) { + possession.detail = + "Not attempted — this is a reader, not the deployment, so it holds no key to answer with."; + } + } + + return { chain, possessionProven: Boolean(privateKey) }; +} + +/** Deployments grouped under the platform they belong to. */ +export async function network(): Promise< + Map +> { + const all = await deployments(); + const byPlatform = new Map< + string, + { platform: string; deployments: DeploymentRecord[] } + >(); + for (const deployment of all) { + const entry = byPlatform.get(deployment.platformEname) ?? { + platform: deployment.platformEname, + deployments: [], + }; + entry.deployments.push(deployment); + byPlatform.set(deployment.platformEname, entry); + } + return byPlatform; +} diff --git a/services/pp-auth-demo/src/lib/server/data.ts b/services/pp-auth-demo/src/lib/server/data.ts new file mode 100644 index 000000000..3c392725d --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/data.ts @@ -0,0 +1,183 @@ +/** + * The signed-in owner's own records, grouped by the domain each one falls under. + * + * Every schema declares its domain, so the grouping is the ontology's, not + * ours: this is exactly the partition a certificate grants against. + */ + +import { envelopes, store_ } from "./evault"; +import { listDomains, listSchemas } from "./domains"; + +export interface OwnedRecord { + id: string; + /** The schema's human title, e.g. "Social Media Post". */ + kind: string; + summary: string; +} + +export interface DomainGroup { + id: string; + label: string; + description: string; + records: OwnedRecord[]; +} + +/** + * A short readable line for a record. + * + * Most schemas carry an obvious text field. Money does not: an Account is a + * balance and a currency, and a Ledger entry is an amount and a description, so + * a summariser that only looks for prose renders your finances as "(no + * readable fields)" and the demonstration shows nothing. + */ +function summarise(parsed: Record): string { + const text = [ + "text", "content", "body", "message", "title", "name", + "displayName", "description", "summary", "label", + ]; + for (const key of text) { + const value = parsed[key]; + if (typeof value === "string" && value.trim()) { + return value.trim().slice(0, 160); + } + } + + // Numeric records: say what the number is rather than falling through. + const amounts: string[] = []; + if (typeof parsed.balance === "number" || typeof parsed.balance === "string") { + amounts.push(`balance ${parsed.balance}`); + } + if (typeof parsed.amount === "number" || typeof parsed.amount === "string") { + amounts.push(`amount ${parsed.amount}`); + } + if (typeof parsed.currencyName === "string" && parsed.currencyName) { + amounts.push(String(parsed.currencyName)); + } + if (typeof parsed.accountType === "string" && parsed.accountType) { + amounts.unshift(String(parsed.accountType)); + } + if (typeof parsed.type === "string" && parsed.type && amounts.length > 0) { + amounts.push(String(parsed.type)); + } + if (amounts.length > 0) return amounts.join(" · ").slice(0, 160); + + const size = typeof parsed.size === "number" ? `${parsed.size} bytes` : null; + if (size && typeof parsed.mimeType === "string") { + return `${parsed.mimeType} · ${size}`; + } + + // Last resort. Identifiers and timestamps are skipped: showing + // "updatedAt: 2026-04-07T04:49:34.455Z" tells a reader nothing about what + // the record is, and a plain admission is more use than filler. + const skip = /(^id$|Id$|At$|EName$|Ename$|^type$|Url$|Hash$)/; + const first = Object.entries(parsed).find( + ([key, value]) => + typeof value === "string" && value.trim().length > 0 && !skip.test(key), + ); + return first + ? `${first[0]}: ${String(first[1]).slice(0, 140)}` + : "(a record with no readable text)"; +} + +/** + * Everything the owner holds, by domain. + * + * Each schema is queried separately because that is the only way an eVault can + * be asked for records; they run together so the page does not wait on them in + * series. A schema the vault holds nothing of simply contributes nothing. + */ +export async function ownedByDomain(ename: string): Promise { + const [schemas, domains] = await Promise.all([listSchemas(), listDomains()]); + if (schemas.length === 0) return []; + + const byDomain = new Map(); + + const results = await Promise.all( + schemas.map(async (schema) => { + const found = await envelopes(ename, schema.id, 10).catch(() => []); + return { schema, found }; + }), + ); + + for (const { schema, found } of results) { + if (found.length === 0) continue; + const list = byDomain.get(schema.domain) ?? []; + for (const record of found) { + list.push({ + id: record.id, + kind: schema.title, + summary: summarise(record.parsed), + }); + } + byDomain.set(schema.domain, list); + } + + return [...byDomain.entries()] + .map(([id, records]) => { + const domain = domains.find((d) => d.id === id); + return { + id, + label: domain?.label ?? id, + description: domain?.description ?? "", + records: records.slice(0, 12), + }; + }) + .sort((a, b) => b.records.length - a.records.length); +} + +/** + * The owner's records in one domain, fetched from the eVault at call time. + * + * This is what a permitted read actually returns. Nothing is cached and + * nothing is precomputed: if a request is allowed, these are the records that + * come back, and if it is refused they are never fetched at all. + */ +export async function recordsInDomain( + ename: string, + domain: string, +): Promise { + const schemas = (await listSchemas()).filter((schema) => schema.domain === domain); + const found = await Promise.all( + schemas.map(async (schema) => { + const records = await envelopes(ename, schema.id, 10).catch(() => []); + return records.map((record) => ({ + id: record.id, + kind: schema.title, + summary: summarise(record.parsed), + })); + }), + ); + return found.flat(); +} + +/** Where a written record goes: the first schema published for that domain. */ +export async function writeTargetFor( + domain: string, +): Promise<{ id: string; title: string } | null> { + const schema = (await listSchemas()).find((entry) => entry.domain === domain); + return schema ? { id: schema.id, title: schema.title } : null; +} + +/** + * Performs a permitted write. + * + * A write that does not write would be exactly the pretence this demonstration + * exists to avoid, so this really does store a record in the owner's eVault — + * with text they typed, into a schema that belongs to the domain the grant + * covered. + */ +export async function writeRecord( + ename: string, + domain: string, + text: string, +): Promise<{ id: string; kind: string } | null> { + const target = await writeTargetFor(domain); + if (!target) return null; + const id = await store_( + ename, + target.id, + { text, name: text, createdAt: new Date().toISOString() }, + [ename], + ); + return { id, kind: target.title }; +} diff --git a/services/pp-auth-demo/src/lib/server/domains.ts b/services/pp-auth-demo/src/lib/server/domains.ts new file mode 100644 index 000000000..0a82d8e57 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/domains.ts @@ -0,0 +1,66 @@ +/** + * The domain vocabulary, and which domain each ontology belongs to. + * + * Owned by the ontology service, not by this app: every schema declares the + * domain it belongs to, so granting a domain is what decides which record + * types a platform may touch. + */ + +import { ontologyUrl } from "./env"; + +export interface Domain { + id: string; + label: string; + description: string; +} + +export interface Schema { + id: string; + title: string; + domain: string; +} + +const TTL_MS = 30 * 60_000; +const STORE = Symbol.for("pp-auth-demo.ontology"); +const store = globalThis as typeof globalThis & { + [STORE]?: { at: number; domains: Domain[]; schemas: Schema[] }; +}; + +async function load(): Promise<{ domains: Domain[]; schemas: Schema[] }> { + const cached = store[STORE]; + if (cached && Date.now() - cached.at < TTL_MS) return cached; + + const base = ontologyUrl(); + const [domains, schemas] = await Promise.all([ + fetch(new URL("/domains", base), { signal: AbortSignal.timeout(15_000) }) + .then((r) => (r.ok ? r.json() : { domains: [] })) + .then((b) => (b.domains ?? []) as Domain[]) + .catch(() => [] as Domain[]), + fetch(new URL("/schemas", base), { signal: AbortSignal.timeout(15_000) }) + .then((r) => (r.ok ? r.json() : [])) + .then((b) => + (Array.isArray(b) ? b : []) + .filter((s: any) => s?.id && s?.domain) + .map((s: any) => ({ id: s.id, title: s.title ?? s.id, domain: s.domain })), + ) + .catch(() => [] as Schema[]), + ]); + + const value = { at: Date.now(), domains, schemas }; + if (domains.length > 0) store[STORE] = value; + return value; +} + +export async function listDomains(): Promise { + return (await load()).domains; +} + +export async function listSchemas(): Promise { + return (await load()).schemas; +} + +/** Domain of one ontology, or null when the ontology is unknown here. */ +export async function domainOf(ontologyId: string): Promise { + const { schemas } = await load(); + return schemas.find((schema) => schema.id === ontologyId)?.domain ?? null; +} diff --git a/services/pp-auth-demo/src/lib/server/env.ts b/services/pp-auth-demo/src/lib/server/env.ts new file mode 100644 index 000000000..e091fd31c --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/env.ts @@ -0,0 +1,63 @@ +import path from "node:path"; +import { config as loadEnv } from "dotenv"; +import { env } from "$env/dynamic/private"; + +/** + * Configuration, read from the repo-root .env in one place. + * + * Deliberately avoids `$env/dynamic/public`: several shared variables in this + * monorepo carry SvelteKit's PUBLIC_ prefix, and importing that module + * serialises the whole public block — every service URL and credential — into + * the HTML of every page. Nothing here is needed in the browser. + */ + +// cwd is services/pp-auth-demo under both `vite dev` and `node build/index.js`. +loadEnv({ path: path.resolve(process.cwd(), "../../.env") }); + +function raw(name: string): string { + return (env[name] ?? process.env[name] ?? "").trim(); +} + +/** Public base URL of this app — the w3ds:// callback target. */ +export function publicUrl(): string { + return raw("PP_AUTH_DEMO_PUBLIC_URL") || "http://localhost:4310"; +} + +export function registryUrl(): string { + const url = raw("REGISTRY_URL") || raw("PUBLIC_REGISTRY_URL"); + if (!url) throw new Error("PUBLIC_REGISTRY_URL is required"); + return url; +} + +export function awarenessUrl(): string { + return raw("AWARENESS_SERVICE_URL") || "https://aaas.w3ds.metastate.foundation"; +} + +export function awarenessApiKey(): string { + return raw("PP_AUTH_DEMO_AWARENESS_API_KEY") || raw("PPA_AWARENESS_API_KEY") || raw("AWARENESS_API_KEY"); +} + +export function ontologyUrl(): string { + return raw("PUBLIC_ONTOLOGY_URL") || "https://ontology.w3ds.metastate.foundation"; +} + +export function ereputationUrl(): string { + return raw("PPA_EREPUTATION_URL") || "https://ereputation.w3ds.metastate.foundation"; +} + +/** + * The reputation service whose scores terms are written against. + * + * There is exactly one, so asking an owner to type its address is asking them + * to get it wrong. When a second exists this becomes a choice again. + */ +export function reputationEngine(): string { + return new URL(ereputationUrl()).host; +} + +export function jwtSecret(): string { + return raw("PP_AUTH_DEMO_JWT_SECRET") || raw("PPA_JWT_SECRET") || "pp-auth-demo-dev-secret"; +} + +/** Name this app presents to the registry when minting its read token. */ +export const PLATFORM_NAME = "pp-auth-demo"; diff --git a/services/pp-auth-demo/src/lib/server/evault.ts b/services/pp-auth-demo/src/lib/server/evault.ts new file mode 100644 index 000000000..e05216198 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/evault.ts @@ -0,0 +1,196 @@ +/** + * Reads from real eVaults: the registry resolves an eName to a vault, and a + * platform token opens it. + * + * That token is exactly the bypass PP Auth exists to replace — the registry + * mints one for any name that asks, and eVault honours it against any vault. + * This app uses it to *read* evidence that is already public, and says so + * rather than pretending it has earned the access. + */ + +import { GraphQLClient, gql } from "graphql-request"; +import { PLATFORM_NAME, registryUrl } from "./env"; + +const BINDING_DOCUMENTS = gql` + query BindingDocuments { + bindingDocuments(first: 50) { + edges { + node { + id + parsed + } + } + } + } +`; + +const ENVELOPES = gql` + query Envelopes($ontologyId: ID!, $first: Int!) { + metaEnvelopes(filter: { ontologyId: $ontologyId }, first: $first) { + edges { + node { + id + parsed + } + } + } + } +`; + +const CREATE = gql` + mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { + createMetaEnvelope(input: $input) { + metaEnvelope { + id + } + errors { + field + message + } + } + } +`; + +const TOKEN = Symbol.for("pp-auth-demo.platformToken"); +const URLS = Symbol.for("pp-auth-demo.evaultUrls"); +const store = globalThis as typeof globalThis & { + [TOKEN]?: Promise; + [URLS]?: Map; +}; +store[URLS] ??= new Map(); + +export function normalizeEName(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return ""; + return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; +} + +async function platformToken(): Promise { + store[TOKEN] ??= (async () => { + const res = await fetch(new URL("/platforms/certification", registryUrl()), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ platform: PLATFORM_NAME }), + signal: AbortSignal.timeout(15_000), + }); + if (!res.ok) throw new Error(`registry token request returned ${res.status}`); + const body = (await res.json()) as { token?: string }; + if (!body.token) throw new Error("registry returned no token"); + return body.token; + })().catch((error) => { + // Do not cache a failure: the next request should try again. + store[TOKEN] = undefined; + throw error; + }); + return store[TOKEN]; +} + +export async function resolveVault(ename: string): Promise { + const normalized = normalizeEName(ename); + const cached = store[URLS]!.get(normalized); + if (cached) return cached; + try { + const res = await fetch( + new URL(`/resolve?w3id=${encodeURIComponent(normalized)}`, registryUrl()), + { signal: AbortSignal.timeout(15_000) }, + ); + if (!res.ok) return null; + const body = (await res.json()) as { evaultUrl?: string; uri?: string }; + const url = body.evaultUrl || body.uri; + if (!url) return null; + store[URLS]!.set(normalized, url); + return url; + } catch { + return null; + } +} + +async function client(ename: string): Promise { + const normalized = normalizeEName(ename); + const [url, token] = await Promise.all([ + resolveVault(normalized), + platformToken(), + ]); + if (!url) return null; + return new GraphQLClient(new URL("/graphql", url).toString(), { + headers: { Authorization: `Bearer ${token}`, "X-ENAME": normalized }, + }); +} + +export interface RawBindingDocument { + id: string; + subject: string; + type: string; + data: Record; + signatures: Array>; +} + +/** The binding documents held in one eVault. */ +export async function bindingDocuments( + ename: string, +): Promise { + const gqlClient = await client(ename); + if (!gqlClient) return []; + try { + const res = await gqlClient.request<{ + bindingDocuments: { + edges: Array<{ node: { id: string; parsed: Record | null } }>; + }; + }>(BINDING_DOCUMENTS); + return res.bindingDocuments.edges + .map((edge) => { + const parsed = edge.node.parsed; + if (!parsed || typeof parsed !== "object") return null; + return { id: edge.node.id, ...parsed } as RawBindingDocument; + }) + .filter((doc): doc is RawBindingDocument => doc !== null); + } catch (error) { + console.warn(`[pp-auth-demo] could not read binding documents for ${ename}:`, error); + return []; + } +} + +/** MetaEnvelopes of one ontology held in one eVault. */ +export async function envelopes( + ename: string, + ontologyId: string, + first = 25, +): Promise }>> { + const gqlClient = await client(ename); + if (!gqlClient) return []; + try { + const res = await gqlClient.request<{ + metaEnvelopes: { + edges: Array<{ node: { id: string; parsed: Record | null } }>; + }; + }>(ENVELOPES, { ontologyId, first }); + return res.metaEnvelopes.edges + .filter((edge) => edge.node.parsed && typeof edge.node.parsed === "object") + .map((edge) => ({ id: edge.node.id, parsed: edge.node.parsed! })); + } catch { + // A vault holding nothing of this ontology errors on some deployments. + return []; + } +} + +/** Writes one record into an eVault. Used only for the owner's own terms. */ +export async function store_( + ename: string, + ontologyId: string, + payload: Record, + acl: string[], +): Promise { + const gqlClient = await client(ename); + if (!gqlClient) throw new Error(`could not resolve an eVault for ${ename}`); + const res = await gqlClient.request<{ + createMetaEnvelope: { + metaEnvelope: { id: string } | null; + errors: Array<{ message: string }> | null; + }; + }>(CREATE, { input: { ontology: ontologyId, payload, acl } }); + const errors = res.createMetaEnvelope.errors; + if (errors?.length) throw new Error(errors.map((e) => e.message).join("; ")); + const id = res.createMetaEnvelope.metaEnvelope?.id; + if (!id) throw new Error("eVault accepted the write but returned no id"); + return id; +} diff --git a/services/pp-auth-demo/src/lib/server/grants.ts b/services/pp-auth-demo/src/lib/server/grants.ts new file mode 100644 index 000000000..6d5c7fa9f --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/grants.ts @@ -0,0 +1,129 @@ +/** + * Access grants, kept in the owner's own eVault as `AccessGrant` records. + * + * Records are append-only, which is what the ontology's `revision` field is + * for: changing what a platform may do writes a new record rather than editing + * the old one, so the history of who was given what, and when it was taken + * away, survives. The newest revision for a (grantee, resource) pair is the one + * in force. + */ + +import type { AccessGrant, Operation } from "@metastate-foundation/auth/platform"; +import { permissionFor } from "@metastate-foundation/auth/platform"; +import { randomUUID } from "node:crypto"; +import { envelopes, store_ } from "./evault"; +import { ACCESS_GRANT_ONTOLOGY } from "./ontology"; + +export interface StoredGrant extends AccessGrant { + grantId: string; + grantorEName: string; + revision: number; + createdAt: string; + updatedAt: string; + revokedAt: string | null; +} + +function key(granteeEName: string | null, resourceType: string): string { + return `${granteeEName ?? "*"}::${resourceType}`; +} + +/** + * The grants in force for one owner: newest revision per grantee and resource. + * + * Revoked records are kept rather than filtered out, so `evaluateGrants` can + * tell "withdrawn" apart from "never held" — which are different things to + * show someone. + */ +export async function currentGrants(ename: string): Promise { + let records: Array<{ id: string; parsed: Record }>; + try { + records = await envelopes(ename, ACCESS_GRANT_ONTOLOGY, 200); + } catch (error) { + console.warn(`[pp-auth-demo] could not read grants for ${ename}:`, error); + return []; + } + + const newest = new Map(); + for (const record of records) { + const raw = record.parsed; + if (raw.isReference === true) continue; + if (raw.grantorEName !== ename) continue; + const resourceType = typeof raw.resourceType === "string" ? raw.resourceType : ""; + const granteeEName = + typeof raw.granteeEName === "string" ? raw.granteeEName : null; + if (!resourceType) continue; + + const grant: StoredGrant = { + grantId: String(raw.grantId ?? ""), + grantorEName: ename, + granteeType: raw.granteeType === "public" ? "public" : "ename", + granteeEName, + resourceType, + permissions: Array.isArray(raw.permissions) + ? raw.permissions.filter((p): p is string => typeof p === "string") + : [], + status: raw.status === "revoked" ? "revoked" : "active", + validFrom: typeof raw.validFrom === "string" ? raw.validFrom : undefined, + validUntil: typeof raw.validUntil === "string" ? raw.validUntil : null, + revision: Number(raw.revision) || 1, + createdAt: String(raw.createdAt ?? ""), + updatedAt: String(raw.updatedAt ?? raw.createdAt ?? ""), + revokedAt: typeof raw.revokedAt === "string" ? raw.revokedAt : null, + }; + + const existing = newest.get(key(granteeEName, resourceType)); + if (!existing || grant.revision > existing.revision) { + newest.set(key(granteeEName, resourceType), grant); + } + } + + return [...newest.values()]; +} + +/** + * Records what one platform may do with one kind of data. + * + * An empty operation list revokes rather than deleting: the record stays and is + * marked withdrawn, so a later reader can see that access was taken away rather + * than finding a silent absence. + */ +export async function setGrant( + ename: string, + granteeEName: string, + resourceType: string, + operations: Operation[], + existing: StoredGrant[], +): Promise { + const previous = existing.find( + (grant) => + grant.granteeEName === granteeEName && grant.resourceType === resourceType, + ); + const now = new Date().toISOString(); + const revoking = operations.length === 0; + + const payload = { + isReference: false, + grantId: previous?.grantId || randomUUID(), + grantorEName: ename, + granteeType: "ename" as const, + granteeEName, + resourceType, + // A revoked grant keeps the permissions it used to carry, so the record + // says what was withdrawn rather than merely that something was. + permissions: revoking + ? previous?.permissions?.length + ? previous.permissions + : [permissionFor(resourceType, "read")] + : operations.map((operation) => permissionFor(resourceType, operation)), + status: revoking ? ("revoked" as const) : ("active" as const), + validFrom: previous?.validFrom ?? now, + validUntil: null, + createdAt: previous?.createdAt || now, + updatedAt: now, + revision: (previous?.revision ?? 0) + 1, + revokedAt: revoking ? now : null, + delegationAllowed: false, + }; + + await store_(ename, ACCESS_GRANT_ONTOLOGY, payload, [ename, granteeEName]); +} diff --git a/services/pp-auth-demo/src/lib/server/keys.ts b/services/pp-auth-demo/src/lib/server/keys.ts new file mode 100644 index 000000000..e626542eb --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/keys.ts @@ -0,0 +1,29 @@ +/** + * Deployment private keys the operator has supplied, held in memory only. + * + * A deployment's private key is the whole of the possession proof, so this app + * never writes one to disk, never logs it, and forgets all of them on restart. + * It accepts one at all because the person running this demonstration is, for + * these deployments, the deployer — supplying the key is how they prove the + * possession link rather than watch it fail. + */ + +const STORE = Symbol.for("pp-auth-demo.deploymentKeys"); +const store = globalThis as typeof globalThis & { [STORE]?: Map }; +const keys: Map = (store[STORE] ??= new Map()); + +export function remember(deploymentEname: string, privateKey: string): void { + keys.set(deploymentEname, privateKey.trim()); +} + +export function forget(deploymentEname: string): void { + keys.delete(deploymentEname); +} + +export function keyFor(deploymentEname: string): string | null { + return keys.get(deploymentEname) ?? null; +} + +export function held(): string[] { + return [...keys.keys()]; +} diff --git a/services/pp-auth-demo/src/lib/server/ontology.ts b/services/pp-auth-demo/src/lib/server/ontology.ts new file mode 100644 index 000000000..44180ff91 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/ontology.ts @@ -0,0 +1,36 @@ +/** Ontology ids this app reads, and the vocabulary they belong to. */ + +export const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +export const PLATFORM_ACCREDITATION_ONTOLOGY = "e1749947-5a10-4973-b9fa-230d8714c36a"; +export const DEPLOYMENT_PROFILE_ONTOLOGY = "d38e0c5b-9d63-4a21-8e8b-1d6b63af64d2"; +export const ACCESS_POLICY_ONTOLOGY = "c7a41f6d-95b8-4e2a-9c33-8f0d1b6e4a72"; +export const ACCESS_GRANT_ONTOLOGY = "15d24c04-a4f3-4e45-a00e-0123926fbc87"; + +export interface AccreditationRecord { + accreditationId: string; + platformEName: string; + platformName: string; + platformVersion: string; + decision: "granted" | "denied"; + level: string | null; + domains: string[]; + statement: string; + reviewedByEName: string; + issuerJwksUri: string; + jws: string; + createdAt: string; +} + +export interface DeploymentRecord { + deploymentEname: string; + deploymentName: string; + environment: string; + deployerEname: string; + platformEname: string; + versionEname: string; + version: string; + releaseTag: string; + commitSha: string; + publicKey: string; + createdAt: string; +} diff --git a/services/pp-auth-demo/src/lib/server/policy.ts b/services/pp-auth-demo/src/lib/server/policy.ts new file mode 100644 index 000000000..f3fad7c26 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/policy.ts @@ -0,0 +1,131 @@ +/** + * The owner's terms, read from and written to their own eVault. + * + * The record is a signed statement, so a reader checks the signature rather + * than trusting this app to have reported it faithfully. Records are + * append-only; the newest valid statement for the owner is the one in force. + */ + +import { + accessPolicyPayload, + defaultAccessPolicy, + parseAccessPolicy, + verifyAccessPolicy, + type AccessPolicyStatement, + type SignedAccessPolicy, +} from "@metastate-foundation/auth/platform"; +import { verifySignature } from "signature-validator/src/index"; +import { registryUrl } from "./env"; +import { envelopes, store_ } from "./evault"; +import { ACCESS_POLICY_ONTOLOGY } from "./ontology"; + +export interface LoadedPolicy { + statement: AccessPolicyStatement; + /** False when nothing has been signed yet and the default applies. */ + signed: boolean; + signature: string | null; + issuedAt: string | null; +} + +async function walletVerifier( + signer: string, + signature: string, + payload: string, +): Promise { + try { + const result = await verifySignature({ + eName: signer, + signature, + payload, + registryBaseUrl: registryUrl(), + }); + return result.valid === true; + } catch { + return false; + } +} + +/** + * The terms in force for one owner. + * + * A record whose signature does not verify is ignored rather than trusted: an + * unverifiable policy is somebody's claim about what the owner wanted, and + * falling back to the default is the safer reading. + */ +export async function currentPolicy(ename: string): Promise { + const fallback: LoadedPolicy = { + statement: defaultAccessPolicy(ename), + signed: false, + signature: null, + issuedAt: null, + }; + + let records: Array<{ id: string; parsed: Record }>; + try { + records = await envelopes(ename, ACCESS_POLICY_ONTOLOGY, 50); + } catch (error) { + console.warn(`[pp-auth-demo] could not read terms for ${ename}:`, error); + return fallback; + } + + const candidates = records + .map((record) => record.parsed) + .filter((parsed) => typeof parsed.issuedAt === "string") + .sort((a, b) => String(b.issuedAt).localeCompare(String(a.issuedAt))); + + for (const candidate of candidates) { + const statement = parseAccessPolicy(candidate); + if (!statement || statement.subject !== ename) continue; + const signed: SignedAccessPolicy = { + statement, + payload: String(candidate.payload ?? ""), + signature: String(candidate.signature ?? ""), + signer: ename, + }; + if (!(await verifyAccessPolicy(signed, walletVerifier))) continue; + return { + statement, + signed: true, + signature: signed.signature, + issuedAt: statement.issuedAt, + }; + } + + return fallback; +} + +/** Everything the wallet needs to sign, derived from a draft. */ +export function prepare( + statement: AccessPolicyStatement, +): { statement: AccessPolicyStatement; payload: string } { + return { statement, payload: accessPolicyPayload(statement) }; +} + +/** + * Publishes signed terms into the owner's eVault, world-readable. + * + * The signature is verified again here before the write. A statement that + * cannot be checked must never be stored, or a later reader will drop it and + * the owner will believe terms are in force that are not. + */ +export async function publish( + statement: AccessPolicyStatement, + payload: string, + signature: string, +): Promise { + const signed: SignedAccessPolicy = { + statement, + payload, + signature, + signer: statement.subject, + }; + if (!(await verifyAccessPolicy(signed, walletVerifier))) { + throw new Error("The signature over these terms did not verify"); + } + return store_( + statement.subject, + ACCESS_POLICY_ONTOLOGY, + { ...statement, payload, signature }, + ["*"], + ); +} diff --git a/services/pp-auth-demo/src/lib/server/session.ts b/services/pp-auth-demo/src/lib/server/session.ts new file mode 100644 index 000000000..6ba2fba00 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/session.ts @@ -0,0 +1,133 @@ +/** + * W3DS sign-in, and wallet signing for the owner's terms. + * + * Both flows are the same shape: we generate a session identifier, the wallet + * signs that identifier, and we verify the signature against the registry. + * + * For the terms the session identifier *is* the canonical policy payload, so + * the resulting signature verifies against the statement on its own — anyone + * holding the record can check it without trusting this app or its session + * store. That is why the terms are worth signing at all. + */ + +import { randomUUID } from "node:crypto"; +// The published dist is CommonJS, which rollup cannot statically analyse for +// named exports when bundling for SSR. Same workaround as PPA and enotary. +import { verifySignature } from "signature-validator/src/index"; +import { publicUrl, registryUrl } from "./env"; + +const TTL_MS = 10 * 60_000; + +interface Pending { + createdAt: number; + kind: "login" | "policy"; + status: "pending" | "done"; + ename?: string; + signature?: string; +} + +/** + * Anchored outside the module graph: the offer, the wallet's callback and the + * browser's poll are three separate requests, and Vite's dev SSR can give each + * its own copy of a module — which silently splits the map, so a signature + * verifies but the page waiting for it never sees it. + */ +const STORE = Symbol.for("pp-auth-demo.sessions"); +const store = globalThis as typeof globalThis & { [STORE]?: Map }; +const sessions: Map = (store[STORE] ??= new Map()); + +function sweep(): void { + const now = Date.now(); + for (const [id, entry] of sessions) { + if (now - entry.createdAt > TTL_MS) sessions.delete(id); + } +} + +/** + * Where the wallet should post back. + * + * Taken from the request being served, not from configuration. The wallet runs + * on a phone, so a callback of `localhost` points it at itself and the login + * silently never completes — and a static env var is wrong the moment the app + * is reached on a different address than whoever set it had in mind. The + * origin the browser used is the one address known to reach this app. + */ +function callback(origin: string | undefined, path: string): string { + return new URL(path, origin || publicUrl()).toString(); +} + +export function createLoginOffer(origin?: string): { uri: string; session: string } { + sweep(); + const session = randomUUID(); + sessions.set(session, { createdAt: Date.now(), kind: "login", status: "pending" }); + const redirect = callback(origin, "/api/auth"); + return { + session, + uri: `w3ds://auth?redirect=${redirect}&session=${session}&platform=pp-auth-demo`, + }; +} + +/** + * A signing offer whose session id is the payload to be signed. `data` is what + * the wallet shows the person before they approve it, so it carries the terms + * in readable form. + */ +export function createSigningOffer( + payload: string, + summary: Record, + origin?: string, +): { uri: string; session: string } { + sweep(); + sessions.set(payload, { createdAt: Date.now(), kind: "policy", status: "pending" }); + const redirect = callback(origin, "/api/sign"); + const data = Buffer.from(JSON.stringify(summary), "utf8").toString("base64"); + return { + session: payload, + uri: `w3ds://sign?session=${encodeURIComponent(payload)}&data=${encodeURIComponent(data)}&redirect_uri=${encodeURIComponent(redirect)}`, + }; +} + +/** Wallet callback for either flow: verify the signature over the session id. */ +export async function complete( + session: string, + ename: string, + signature: string, +): Promise<{ ok: boolean; error?: string }> { + const pending = sessions.get(session); + if (!pending) return { ok: false, error: "unknown or expired session" }; + + const result = await verifySignature({ + eName: ename, + signature, + payload: session, + registryBaseUrl: registryUrl(), + }); + if (!result.valid) { + return { ok: false, error: result.error ?? "invalid signature" }; + } + + pending.ename = ename; + pending.signature = signature; + pending.status = "done"; + return { ok: true }; +} + +/** + * Polled by the page. "unknown" is distinguished from "pending" so an expired + * or cross-process session tells the page to start again instead of waiting + * forever. + */ +export function poll( + session: string, +): + | { status: "pending" } + | { status: "unknown" } + | { status: "done"; ename: string; signature: string } { + const pending = sessions.get(session); + if (!pending) return { status: "unknown" }; + if (pending.status === "done" && pending.ename && pending.signature) { + sessions.delete(session); + return { status: "done", ename: pending.ename, signature: pending.signature }; + } + return { status: "pending" }; +} diff --git a/services/pp-auth-demo/src/lib/server/token.ts b/services/pp-auth-demo/src/lib/server/token.ts new file mode 100644 index 000000000..7ea0dba75 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/token.ts @@ -0,0 +1,55 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { jwtSecret } from "./env"; + +/** Signed session cookie. Nothing sensitive is in it beyond the eName. */ + +export const COOKIE = "pp_auth_demo_session"; +const MAX_AGE_S = 7 * 24 * 3600; + +function sign(value: string): string { + return createHmac("sha256", jwtSecret()).update(value).digest("base64url"); +} + +export function mint(ename: string): string { + const body = Buffer.from( + JSON.stringify({ ename, exp: Date.now() + MAX_AGE_S * 1000 }), + "utf8", + ).toString("base64url"); + return `${body}.${sign(body)}`; +} + +export function read(token: string | undefined): { ename: string } | null { + if (!token) return null; + const [body, signature] = token.split("."); + if (!body || !signature) return null; + const expected = sign(body); + if ( + expected.length !== signature.length || + !timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) + ) { + return null; + } + try { + const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8")); + if (typeof claims.ename !== "string" || Date.now() > claims.exp) return null; + return { ename: claims.ename }; + } catch { + return null; + } +} + +/** + * `secure` follows the actual scheme rather than SvelteKit's default, which + * sets it for any non-localhost host. Over plain HTTP on a LAN address — how + * this is reached from a phone — a Secure cookie is silently dropped and the + * login appears to succeed on the server while the browser never advances. + */ +export function cookieOptions(url: URL) { + return { + path: "/", + httpOnly: true, + sameSite: "lax" as const, + secure: url.protocol === "https:", + maxAge: MAX_AGE_S, + }; +} diff --git a/services/pp-auth-demo/src/routes/+layout.server.ts b/services/pp-auth-demo/src/routes/+layout.server.ts new file mode 100644 index 000000000..5c5a9f1e0 --- /dev/null +++ b/services/pp-auth-demo/src/routes/+layout.server.ts @@ -0,0 +1,6 @@ +import type { LayoutServerLoad } from "./$types"; + +export const load: LayoutServerLoad = async ({ locals, url }) => ({ + user: locals.user, + pathname: url.pathname, +}); diff --git a/services/pp-auth-demo/src/routes/+layout.svelte b/services/pp-auth-demo/src/routes/+layout.svelte new file mode 100644 index 000000000..20027148a --- /dev/null +++ b/services/pp-auth-demo/src/routes/+layout.svelte @@ -0,0 +1,53 @@ + + +
+
+
+
+

Post Platforms Association

+

+ What each platform can reach, and why +

+
+ {#if data.user} +
+ {data.user.ename} + +
+ {/if} +
+ + {#if data.user} + + {/if} +
+ +
+ {@render children()} +
+
diff --git a/services/pp-auth-demo/src/routes/+page.server.ts b/services/pp-auth-demo/src/routes/+page.server.ts new file mode 100644 index 000000000..725ee09e0 --- /dev/null +++ b/services/pp-auth-demo/src/routes/+page.server.ts @@ -0,0 +1,6 @@ +import { redirect } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async () => { + throw redirect(302, "/platforms"); +}; diff --git a/services/pp-auth-demo/src/routes/acl/+page.server.ts b/services/pp-auth-demo/src/routes/acl/+page.server.ts new file mode 100644 index 000000000..1112445c2 --- /dev/null +++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts @@ -0,0 +1,75 @@ +import { accreditations, deployments, platformProfile } from "$lib/server/aaas"; +import { listDomains } from "$lib/server/domains"; +import { currentGrants } from "$lib/server/grants"; +import { held } from "$lib/server/keys"; +import type { PageServerLoad } from "./$types"; + +/** + * Everything needed to decide, and to see the decision. + * + * The domain list is the whole published vocabulary, not just what each + * platform was certified for. Offering only the certified ones would hide the + * most important case: asking for something a platform has no business with, + * and watching the certificate refuse it before permissions are even reached. + */ +export const load: PageServerLoad = async ({ locals }) => { + const ename = locals.user!.ename; + + const [records, grants, domains, allDeployments] = await Promise.all([ + accreditations().catch(() => []), + currentGrants(ename), + listDomains().catch(() => []), + deployments().catch(() => []), + ]); + + const granted = new Map(); + for (const record of records) { + if (record.decision !== "granted") continue; + if (!granted.has(record.platformEName)) granted.set(record.platformEName, record); + } + + const withKeys = new Set(held()); + + const platforms = await Promise.all( + [...granted.values()].map(async (record) => { + const profile = await platformProfile(record.platformEName); + const mine = allDeployments.filter( + (deployment) => deployment.platformEname === record.platformEName, + ); + return { + ename: record.platformEName, + name: profile?.displayName || record.platformName, + level: record.level, + version: record.platformVersion, + certifiedDomains: record.domains ?? [], + deployments: mine.map((deployment) => ({ + ename: deployment.deploymentEname, + name: deployment.deploymentName, + environment: deployment.environment, + version: deployment.version, + keyHeld: withKeys.has(deployment.deploymentEname), + })), + grants: domains.map((entry) => { + const domain = entry.id; + const grant = grants.find( + (held) => + held.granteeEName === record.platformEName && + held.resourceType === domain, + ); + const active = grant && grant.status === "active"; + return { + domain, + label: entry.label, + certified: (record.domains ?? []).includes(domain), + read: Boolean(active && grant!.permissions.includes(`${domain}:Read`)), + write: Boolean(active && grant!.permissions.includes(`${domain}:Write`)), + revoked: Boolean(grant && grant.status === "revoked"), + revision: grant?.revision ?? 0, + }; + }), + }; + }), + ); + + return { ename, platforms }; +}; diff --git a/services/pp-auth-demo/src/routes/acl/+page.svelte b/services/pp-auth-demo/src/routes/acl/+page.svelte new file mode 100644 index 000000000..9f68d6a09 --- /dev/null +++ b/services/pp-auth-demo/src/routes/acl/+page.svelte @@ -0,0 +1,73 @@ + + +
+
+

Permissions

+

What each platform may do

+

+ Being certified for a kind of data is not permission to do anything with + it. Reading your posts is not the same as writing to them. Ask for + something on this platform's behalf and see what happens — and what comes + back out of your eVault when it is allowed. +

+
+ + {#if data.platforms.length === 0} +
+

+ No platform on the network is certified yet, so there is nothing to + permit. This fills in on its own once the association grants one. +

+
+ {/if} + + {#each data.platforms as platform (platform.ename)} +
+
+
+

{platform.name}

+

+ Certified {platform.level} · {platform.version} +

+
+
+ {#each platform.certifiedDomains as domain (domain)} + {domain} + {/each} +
+
+ + {#if platform.deployments.length > 0} +
+

+ Deployment keys +

+ {#each platform.deployments as deployment (deployment.ename)} + + {/each} +
+ + + {:else} +

+ Nothing is deployed from this platform, so there is nothing to ask on + its behalf. +

+ {/if} +
+ {/each} +
diff --git a/services/pp-auth-demo/src/routes/api/auth/+server.ts b/services/pp-auth-demo/src/routes/api/auth/+server.ts new file mode 100644 index 000000000..50ccc30bb --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/auth/+server.ts @@ -0,0 +1,22 @@ +import { json } from "@sveltejs/kit"; +import { complete } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +/** Wallet callback for w3ds://auth. Field names vary by wallet build. */ +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Record; + const session = String(body.session ?? body.sessionId ?? ""); + const ename = String(body.ename ?? body.w3id ?? body.eName ?? ""); + const signature = String(body.signature ?? ""); + + if (!session || !ename || !signature) { + return json({ error: "session, ename and signature are required" }, { status: 400 }); + } + + const result = await complete(session, ename, signature); + if (!result.ok) { + console.warn("[pp-auth-demo/auth] rejected:", result.error); + return json({ error: result.error }, { status: 401 }); + } + return json({ ok: true }); +}; diff --git a/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts b/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts new file mode 100644 index 000000000..c265a3350 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/auth/logout/+server.ts @@ -0,0 +1,8 @@ +import { redirect } from "@sveltejs/kit"; +import { COOKIE, cookieOptions } from "$lib/server/token"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies, url }) => { + cookies.delete(COOKIE, cookieOptions(url)); + throw redirect(303, "/login"); +}; diff --git a/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts new file mode 100644 index 000000000..cc3ed86b1 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts @@ -0,0 +1,6 @@ +import { json } from "@sveltejs/kit"; +import { createLoginOffer } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ url }) => + json(createLoginOffer(url.origin)); diff --git a/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts b/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts new file mode 100644 index 000000000..f42046c2f --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts @@ -0,0 +1,16 @@ +import { json } from "@sveltejs/kit"; +import { poll } from "$lib/server/session"; +import { COOKIE, cookieOptions, mint } from "$lib/server/token"; +import type { RequestHandler } from "./$types"; + +/** Polled by the login page until the wallet has answered. */ +export const GET: RequestHandler = async ({ params, cookies, url }) => { + const result = poll(params.session); + if (result.status === "unknown") { + return json({ status: "unknown" }, { status: 410 }); + } + if (result.status !== "done") return json({ status: "pending" }); + + cookies.set(COOKIE, mint(result.ename), cookieOptions(url)); + return json({ status: "authenticated", ename: result.ename }); +}; diff --git a/services/pp-auth-demo/src/routes/api/grants/+server.ts b/services/pp-auth-demo/src/routes/api/grants/+server.ts new file mode 100644 index 000000000..6d6a1e065 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/grants/+server.ts @@ -0,0 +1,38 @@ +import { json } from "@sveltejs/kit"; +import type { Operation } from "@metastate-foundation/auth/platform"; +import { currentGrants, setGrant } from "$lib/server/grants"; +import type { RequestHandler } from "./$types"; + +/** + * Records what one platform may do with one kind of data. + * + * Writes an `AccessGrant` into the owner's own eVault. Clearing both operations + * withdraws the grant rather than deleting it, so the record shows access was + * taken away rather than never given. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const { platformEname, domain, operations } = (await request.json()) as { + platformEname?: string; + domain?: string; + operations?: string[]; + }; + if (!platformEname || !domain) { + return json({ error: "platformEname and domain are required" }, { status: 400 }); + } + + const wanted = (operations ?? []).filter( + (operation): operation is Operation => operation === "read" || operation === "write", + ); + + const ename = locals.user!.ename; + try { + await setGrant(ename, platformEname, domain, wanted, await currentGrants(ename)); + return json({ ok: true }); + } catch (error) { + console.error("[pp-auth-demo/grants] could not write the grant:", error); + return json( + { error: error instanceof Error ? error.message : "could not save" }, + { status: 500 }, + ); + } +}; diff --git a/services/pp-auth-demo/src/routes/api/key/+server.ts b/services/pp-auth-demo/src/routes/api/key/+server.ts new file mode 100644 index 000000000..4efcfafc0 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/key/+server.ts @@ -0,0 +1,26 @@ +import { json } from "@sveltejs/kit"; +import { forget, remember } from "$lib/server/keys"; +import type { RequestHandler } from "./$types"; + +/** + * Accepts a deployment's private key so the possession link can be proved. + * + * Held in memory for this process only — never written to disk, never logged, + * gone on restart. It is accepted at all because whoever is running this holds + * these deployments, and supplying the key is how they demonstrate the one + * link that reading public records cannot establish. + */ +export const POST: RequestHandler = async ({ request }) => { + const { deploymentEname, privateKey } = (await request.json()) as { + deploymentEname?: string; + privateKey?: string; + }; + if (!deploymentEname) return json({ error: "deploymentEname is required" }, { status: 400 }); + + if (!privateKey?.trim()) { + forget(deploymentEname); + return json({ keyHeld: false }); + } + remember(deploymentEname, privateKey); + return json({ keyHeld: true }); +}; diff --git a/services/pp-auth-demo/src/routes/api/request/+server.ts b/services/pp-auth-demo/src/routes/api/request/+server.ts new file mode 100644 index 000000000..52f870f4c --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/request/+server.ts @@ -0,0 +1,116 @@ +import { json } from "@sveltejs/kit"; +import { authorize, type Operation, type PlatformClaim } from "@metastate-foundation/auth/platform"; +import { deployments, platformProfile } from "$lib/server/aaas"; +import { assemble, verify } from "$lib/server/chain"; +import { recordsInDomain, writeRecord } from "$lib/server/data"; +import { currentGrants } from "$lib/server/grants"; +import { keyFor } from "$lib/server/keys"; +import { currentPolicy } from "$lib/server/policy"; +import type { RequestHandler } from "./$types"; + +/** + * One request, all the way through. + * + * A deployment proves what it is, and then the three gates decide what it may + * do: the association's certificate, the owner's terms, and the grants. The + * response reports each stage separately so it is clear which one refused. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const body = (await request.json()) as { + deploymentEname?: string; + domain?: string; + operation?: string; + text?: string; + }; + const operation: Operation = body.operation === "write" ? "write" : "read"; + const domain = String(body.domain ?? ""); + const ename = locals.user!.ename; + + const all = await deployments(); + const deployment = all.find((d) => d.deploymentEname === body.deploymentEname); + if (!deployment || !domain) { + return json({ error: "Unknown deployment or domain" }, { status: 404 }); + } + + const assembled = await assemble(deployment); + if (!assembled.evidence) { + return json({ + stage: "evidence", + missing: assembled.missing, + chain: null, + decision: null, + }); + } + + const { chain } = await verify( + assembled.evidence, + ename, + keyFor(deployment.deploymentEname), + ); + + // Without a proven identity there is nothing to authorise. Refusing here is + // the whole point: an unproven caller does not get to reach anything, + // however generous the grants behind it are. + if (!chain.ok || !chain.claim) { + return json({ stage: "handshake", chain, decision: null, missing: [] }); + } + + const [policy, grants, profile] = await Promise.all([ + currentPolicy(ename), + currentGrants(ename), + platformProfile(deployment.platformEname), + ]); + + const claim: PlatformClaim = { + ...chain.claim, + platformName: profile?.displayName || chain.claim.platformName, + }; + + const decision = authorize(policy.statement, { + claim, + domain, + operation, + grants, + }); + + if (!decision.allowed) { + // Nothing is fetched. A refusal that still read the data and then + // declined to show it would not be a refusal at all. + return json({ stage: "authorised", chain, decision, records: null, wrote: null }); + } + + if (operation === "write") { + const text = String(body.text ?? "").trim(); + if (!text) { + return json({ + stage: "authorised", + chain, + decision, + records: null, + wrote: null, + note: "Permitted, but nothing was written — no text was given.", + }); + } + const wrote = await writeRecord(ename, domain, text); + return json({ + stage: "authorised", + chain, + decision, + records: await recordsInDomain(ename, domain), + wrote, + }); + } + + // The point of the whole exercise: a permitted read really does go to the + // eVault and come back with the records. + return json({ + stage: "authorised", + chain, + decision, + records: await recordsInDomain(ename, domain), + wrote: null, + }); +}; + +export const GET: RequestHandler = async () => + json({ error: "POST a deployment, domain and operation" }, { status: 405 }); diff --git a/services/pp-auth-demo/src/routes/api/sign/+server.ts b/services/pp-auth-demo/src/routes/api/sign/+server.ts new file mode 100644 index 000000000..7f65deef9 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/sign/+server.ts @@ -0,0 +1,41 @@ +import { json } from "@sveltejs/kit"; +import { complete } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +/** + * Wallet callback for w3ds://sign. + * + * The wallet signs the session id and posts it back as `message`. For the + * owner's terms that session id is the canonical payload of the statement, so + * this signature is over the terms themselves — the page polling + * /api/terms/status is what turns it into a published record. + * + * Field names vary between wallet builds, so accept the shapes in use. + */ +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json().catch(() => ({}))) as Record; + const session = String(body.sessionId ?? body.session ?? ""); + const ename = String(body.w3id ?? body.ename ?? body.eName ?? ""); + const signature = String(body.signature ?? ""); + + if (!session || !ename || !signature) { + return json( + { error: "sessionId, w3id and signature are required" }, + { status: 400 }, + ); + } + + // The wallet echoes what it signed; if it disagrees with the session we are + // tracking, something has been substituted along the way. + const message = body.message === undefined ? session : String(body.message); + if (message !== session) { + return json({ error: "signed payload does not match the session" }, { status: 400 }); + } + + const result = await complete(session, ename, signature); + if (!result.ok) { + console.warn("[pp-auth-demo/sign] rejected:", result.error); + return json({ error: result.error }, { status: 401 }); + } + return json({ ok: true }); +}; diff --git a/services/pp-auth-demo/src/routes/api/terms/+server.ts b/services/pp-auth-demo/src/routes/api/terms/+server.ts new file mode 100644 index 000000000..919e60445 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts @@ -0,0 +1,57 @@ +import { json } from "@sveltejs/kit"; +import { + CERTIFICATION_LEVELS, + defaultAccessPolicy, + type CertificationLevel, +} from "@metastate-foundation/auth/platform"; +import { randomUUID } from "node:crypto"; +import { reputationEngine } from "$lib/server/env"; +import { prepare } from "$lib/server/policy"; +import { createSigningOffer } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +/** + * Turns a draft into a statement and asks the wallet to sign it. + * + * The signing session id is the canonical payload itself, so what the wallet + * signs is exactly the digest of these terms — the resulting signature stands + * on its own, without anyone having to trust this app's session store. + */ +export const POST: RequestHandler = async ({ request, locals, url }) => { + const body = (await request.json()) as Record; + const ename = locals.user!.ename; + + const level = String(body.minimumLevel ?? "") as CertificationLevel; + if (!CERTIFICATION_LEVELS.includes(level)) { + return json({ error: "Pick a level" }, { status: 400 }); + } + const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; + + const statement = { + ...defaultAccessPolicy(ename), + minimumLevel: level, + // Named in the statement so it is on the record which service the owner + // accepted scores from, even while there is only one to accept. + reputationEngine: reputationEngine(), + minimumReputation: null, + allowedDomains: null, + deniedDomains: strings(body.deniedDomains), + issuedAt: new Date().toISOString(), + nonce: randomUUID(), + }; + + const prepared = prepare(statement); + const offer = createSigningOffer( + prepared.payload, + { + message: "Set the terms platforms must meet to reach your data", + minimumLevel: statement.minimumLevel, + reputationFrom: statement.reputationEngine, + refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing", + }, + url.origin, + ); + + return json({ statement, payload: prepared.payload, uri: offer.uri }); +}; diff --git a/services/pp-auth-demo/src/routes/api/terms/status/+server.ts b/services/pp-auth-demo/src/routes/api/terms/status/+server.ts new file mode 100644 index 000000000..885f87e84 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/terms/status/+server.ts @@ -0,0 +1,42 @@ +import { json } from "@sveltejs/kit"; +import { parseAccessPolicy } from "@metastate-foundation/auth/platform"; +import { publish } from "$lib/server/policy"; +import { poll } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +/** + * Polled while the wallet is deciding. Once it signs, the terms are published + * into the owner's own eVault — the signature is checked again before the + * write, so terms that cannot be verified never become the record. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const { payload, statement } = (await request.json()) as { + payload?: string; + statement?: unknown; + }; + if (!payload) return json({ error: "payload is required" }, { status: 400 }); + + const result = poll(payload); + if (result.status !== "done") return json({ status: result.status }); + + if (result.ename !== locals.user!.ename) { + return json( + { status: "rejected", error: "Those terms were signed by a different person." }, + { status: 403 }, + ); + } + + const parsed = parseAccessPolicy(statement); + if (!parsed) return json({ status: "rejected", error: "Malformed terms" }, { status: 400 }); + + try { + const id = await publish(parsed, payload, result.signature); + return json({ status: "published", id }); + } catch (error) { + console.error("[pp-auth-demo/terms] publish failed:", error); + return json( + { status: "rejected", error: error instanceof Error ? error.message : "failed" }, + { status: 500 }, + ); + } +}; diff --git a/services/pp-auth-demo/src/routes/api/verify/+server.ts b/services/pp-auth-demo/src/routes/api/verify/+server.ts new file mode 100644 index 000000000..33dc2467e --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/verify/+server.ts @@ -0,0 +1,33 @@ +import { json } from "@sveltejs/kit"; +import { assemble, verify } from "$lib/server/chain"; +import { deployments } from "$lib/server/aaas"; +import { keyFor } from "$lib/server/keys"; +import type { RequestHandler } from "./$types"; + +/** + * Verifies one real deployment's chain of trust, now. + * + * Every link is checked against evidence read from the network at request + * time. Possession is only provable when this app has been given that + * deployment's private key; otherwise it fails and says why, which is the + * correct outcome rather than a gap to paper over. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + const { deploymentEname } = (await request.json()) as { deploymentEname?: string }; + const all = await deployments(); + const deployment = all.find((d) => d.deploymentEname === deploymentEname); + if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 }); + + const assembled = await assemble(deployment); + if (!assembled.evidence) { + return json({ chain: null, missing: assembled.missing, possessionProven: false }); + } + + const { chain, possessionProven } = await verify( + assembled.evidence, + locals.user!.ename, + keyFor(deployment.deploymentEname), + ); + + return json({ chain, missing: [], possessionProven }); +}; diff --git a/services/pp-auth-demo/src/routes/data/+page.server.ts b/services/pp-auth-demo/src/routes/data/+page.server.ts new file mode 100644 index 000000000..159e1b88f --- /dev/null +++ b/services/pp-auth-demo/src/routes/data/+page.server.ts @@ -0,0 +1,65 @@ +import { authorize, type PlatformClaim } from "@metastate-foundation/auth/platform"; +import { accreditations, platformProfile } from "$lib/server/aaas"; +import { ownedByDomain } from "$lib/server/data"; +import { currentPolicy } from "$lib/server/policy"; +import type { PageServerLoad } from "./$types"; + +/** + * The owner's own records, and — for each certified platform — what it would + * be allowed to reach and what it would be refused. + * + * The decisions here are the real ones: the real certificate's domains, the + * owner's real signed terms, and the same `authorize` an eVault would call. + * What is not being claimed is that these platforms have asked; this is what + * would happen if they did. + */ +export const load: PageServerLoad = async ({ locals }) => { + const ename = locals.user!.ename; + + const [groups, policy, records] = await Promise.all([ + ownedByDomain(ename).catch((error) => { + console.error("[pp-auth-demo/data] could not read records:", error); + return []; + }), + currentPolicy(ename), + accreditations(), + ]); + + // Newest granted decision per platform. + const granted = new Map(); + for (const record of records) { + if (record.decision !== "granted") continue; + if (!granted.has(record.platformEName)) granted.set(record.platformEName, record); + } + + const domainIds = groups.map((group) => group.id); + + const platforms = await Promise.all( + [...granted.values()].map(async (record) => { + const profile = await platformProfile(record.platformEName); + const claim: PlatformClaim = { + platformEname: record.platformEName, + platformName: profile?.displayName || record.platformName, + deploymentEname: "", + version: record.platformVersion, + level: (record.level ?? "L0") as PlatformClaim["level"], + domains: record.domains ?? [], + deployerEname: "", + reviewedByEName: record.reviewedByEName, + }; + return { + ename: record.platformEName, + name: claim.platformName, + level: record.level, + version: record.platformVersion, + certifiedDomains: record.domains ?? [], + decisions: domainIds.map((domain) => ({ + domain, + ...authorize(policy.statement, { claim, domain }), + })), + }; + }), + ); + + return { ename, groups, platforms, policy }; +}; diff --git a/services/pp-auth-demo/src/routes/data/+page.svelte b/services/pp-auth-demo/src/routes/data/+page.svelte new file mode 100644 index 000000000..7dea72672 --- /dev/null +++ b/services/pp-auth-demo/src/routes/data/+page.svelte @@ -0,0 +1,111 @@ + + +
+
+

Your data

+

What is in your eVault

+

+ Your own records, grouped the way the ontology groups them. That grouping is + what a certificate is written against, so it is also what decides which + platform can see which of these. +

+
+ + {#if data.platforms.length > 0 && data.groups.length > 0} +
+
+

Who could reach what

+

+ Each certified platform against each kind of data you hold, decided + by its certificate and your terms. +

+
+
+ + + + + {#each data.groups as group (group.id)} + + {/each} + + + + {#each data.platforms as platform (platform.ename)} + + + {#each platform.decisions as decision (decision.domain)} + + {/each} + + {/each} + +
Platform{group.label}
+

{platform.name}

+

+ {platform.level} · {platform.version} +

+
+ {#if decision.allowed} + + Allowed + + {:else} + + Refused + + {/if} +
+
+
+ {#each data.platforms as platform (platform.ename)} + {#each platform.decisions.filter((d) => !d.allowed) as decision (decision.domain)} +

+ {platform.name} + · {labelFor(decision.domain)} — {decision.reason} +

+ {/each} + {/each} +
+
+ {/if} + + {#if data.groups.length === 0} +
+

+ Nothing readable was found in your eVault. That may mean it is empty, or + that it is not reachable from here right now. +

+
+ {/if} + + {#each data.groups as group (group.id)} +
+
+

{group.label}

+ {#if group.description} +

{group.description}

+ {/if} +
+
    + {#each group.records as record (record.id)} +
  • +

    {record.kind}

    +

    {record.summary}

    +
  • + {/each} +
+
+ {/each} +
diff --git a/services/pp-auth-demo/src/routes/login/+page.svelte b/services/pp-auth-demo/src/routes/login/+page.svelte new file mode 100644 index 000000000..877998543 --- /dev/null +++ b/services/pp-auth-demo/src/routes/login/+page.svelte @@ -0,0 +1,63 @@ + + +
+
+
+

Sign in

+

Scan with your wallet

+

+ This reads your own eVault, so it needs to know it is you. +

+
+ + {#if uri} +
+ +
+ {:else if error} +

{error}

+ + {:else} +

Preparing a code…

+ {/if} +
+
diff --git a/services/pp-auth-demo/src/routes/platforms/+page.server.ts b/services/pp-auth-demo/src/routes/platforms/+page.server.ts new file mode 100644 index 000000000..3bdb56b8d --- /dev/null +++ b/services/pp-auth-demo/src/routes/platforms/+page.server.ts @@ -0,0 +1,102 @@ +import { accreditations, deployments, isConfigured, platformProfile } from "$lib/server/aaas"; +import { accreditationFor } from "$lib/server/chain"; +import { held } from "$lib/server/keys"; +import type { PageServerLoad } from "./$types"; + +export interface PlatformView { + ename: string; + name: string; + description: string; + currentVersion: string; + logoUrl: string | null; + deployments: Array<{ + ename: string; + name: string; + environment: string; + version: string; + releaseTag: string; + commitSha: string; + deployerEname: string; + publicKey: string; + /** The decision covering this deployment's exact version. */ + certified: { level: string | null; domains: string[]; decision: string } | null; + keyHeld: boolean; + }>; +} + +/** + * Every platform the network knows about that has at least one deployment or + * one certification decision. Nothing is seeded: an empty page means nothing + * has been deployed or certified yet, which is a true statement about the + * network rather than a failure of this app. + */ +export const load: PageServerLoad = async () => { + if (!isConfigured()) { + return { configured: false, platforms: [] as PlatformView[], error: null }; + } + + try { + const [allDeployments, allAccreditations] = await Promise.all([ + deployments(), + accreditations(), + ]); + + const enames = new Set([ + ...allDeployments.map((d) => d.platformEname), + ...allAccreditations.map((a) => a.platformEName), + ]); + + const withKeys = new Set(held()); + + const platforms = await Promise.all( + [...enames].map(async (ename): Promise => { + const profile = await platformProfile(ename); + const mine = allDeployments + .filter((d) => d.platformEname === ename) + .sort((a, b) => a.environment.localeCompare(b.environment)); + return { + ename, + name: profile?.displayName || profile?.platformName || ename, + description: profile?.description ?? "", + currentVersion: profile?.version ?? "", + logoUrl: profile?.logoUrl ?? null, + deployments: mine.map((deployment) => { + const decision = accreditationFor( + allAccreditations, + ename, + deployment.version, + ); + return { + ename: deployment.deploymentEname, + name: deployment.deploymentName, + environment: deployment.environment, + version: deployment.version, + releaseTag: deployment.releaseTag, + commitSha: deployment.commitSha, + deployerEname: deployment.deployerEname, + publicKey: deployment.publicKey, + keyHeld: withKeys.has(deployment.deploymentEname), + certified: decision + ? { + level: decision.level, + domains: decision.domains ?? [], + decision: decision.decision, + } + : null, + }; + }), + }; + }), + ); + + platforms.sort((a, b) => b.deployments.length - a.deployments.length); + return { configured: true, platforms, error: null }; + } catch (error) { + console.error("[pp-auth-demo/platforms] load failed:", error); + return { + configured: true, + platforms: [] as PlatformView[], + error: error instanceof Error ? error.message : "could not read the network", + }; + } +}; diff --git a/services/pp-auth-demo/src/routes/platforms/+page.svelte b/services/pp-auth-demo/src/routes/platforms/+page.svelte new file mode 100644 index 000000000..4b72e424d --- /dev/null +++ b/services/pp-auth-demo/src/routes/platforms/+page.svelte @@ -0,0 +1,82 @@ + + +
+
+

Platforms

+

+ Every platform running on the network +

+

+ Read live from the network — the platforms, their releases, the deployments + actually running them, and the decisions the association has issued. Check + any deployment and it proves what it is, from scratch, against records + anyone can read. +

+
+ + {#if !data.configured} +
+

+ This app has no key for the awareness network, so it cannot see what is + out there. Set PPA_AWARENESS_API_KEY and reload. +

+
+ {:else if data.error} +
+

Could not read the network: {data.error}

+
+ {:else if data.platforms.length === 0} +
+

+ Nothing has been deployed or certified yet. This page fills in on its own + once a platform ships a release and the association decides on it. +

+
+ {/if} + + {#each data.platforms as platform (platform.ename)} +
+
+
+

{platform.name}

+ {#if platform.description} +

{platform.description}

+ {/if} +

{platform.ename}

+
+ {#if platform.deployments[0]?.certified?.decision === "granted"} +
+ + {platform.deployments[0].certified?.level} + + {#each platform.deployments[0].certified?.domains ?? [] as domain (domain)} + {domain} + {/each} +
+ {/if} +
+ + {#if platform.deployments.length === 0} +

+ Certified, but nothing is deployed from it yet. +

+ {:else} +
+ {#each platform.deployments as deployment (deployment.ename)} + + {/each} +
+ {/if} +
+ {/each} +
diff --git a/services/pp-auth-demo/src/routes/terms/+page.server.ts b/services/pp-auth-demo/src/routes/terms/+page.server.ts new file mode 100644 index 000000000..9b414e17c --- /dev/null +++ b/services/pp-auth-demo/src/routes/terms/+page.server.ts @@ -0,0 +1,13 @@ +import { listDomains } from "$lib/server/domains"; +import { reputationEngine } from "$lib/server/env"; +import { currentPolicy } from "$lib/server/policy"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + const ename = locals.user!.ename; + const [policy, domains] = await Promise.all([ + currentPolicy(ename), + listDomains().catch(() => []), + ]); + return { ename, policy, domains, reputationEngine: reputationEngine() }; +}; diff --git a/services/pp-auth-demo/src/routes/terms/+page.svelte b/services/pp-auth-demo/src/routes/terms/+page.svelte new file mode 100644 index 000000000..76516bf79 --- /dev/null +++ b/services/pp-auth-demo/src/routes/terms/+page.svelte @@ -0,0 +1,53 @@ + + +
+
+

Your terms

+

What you will deal with

+

+ The association says what a platform was found to be. You decide what that + is worth. You sign your answers with your wallet and they are kept in your + own eVault, so they travel with you and anyone can check them — including a + platform working out whether it is worth asking. +

+ {#if data.policy.signed} +

+ Signed on {new Date(data.policy.issuedAt ?? "").toLocaleString()}. +

+ {:else} +

+ You have not set any terms yet, so the default applies: nothing below + {data.policy.statement.minimumLevel}. +

+ {/if} +
+ + {#key data.policy.statement.nonce} + + {/key} + + {#if data.policy.signed} +
+
+ + The statement you signed + +
{JSON.stringify(
+                        data.policy.statement,
+                        null,
+                        2,
+                    )}
+

Signature: {data.policy.signature}

+
+
+ {/if} +
diff --git a/services/pp-auth-demo/src/svelte-qrcode.d.ts b/services/pp-auth-demo/src/svelte-qrcode.d.ts new file mode 100644 index 000000000..cb853dae7 --- /dev/null +++ b/services/pp-auth-demo/src/svelte-qrcode.d.ts @@ -0,0 +1,20 @@ +/** + * svelte-qrcode ships no type declarations — its package exports only the + * `svelte` condition pointing at raw component source. Declare the props we + * use so `svelte-check` can see the component. + */ +declare module "svelte-qrcode" { + import type { Component } from "svelte"; + + const QrCode: Component<{ + value?: string; + size?: string | number; + color?: string; + background?: string; + padding?: number; + errorCorrection?: "L" | "M" | "Q" | "H"; + className?: string; + }>; + + export default QrCode; +} diff --git a/services/pp-auth-demo/svelte.config.js b/services/pp-auth-demo/svelte.config.js new file mode 100644 index 000000000..4ca2087b8 --- /dev/null +++ b/services/pp-auth-demo/svelte.config.js @@ -0,0 +1,14 @@ +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter(), + env: { + dir: "../../", + }, + }, +}; + +export default config; diff --git a/services/pp-auth-demo/tsconfig.json b/services/pp-auth-demo/tsconfig.json new file mode 100644 index 000000000..104691d2d --- /dev/null +++ b/services/pp-auth-demo/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/services/pp-auth-demo/vite.config.ts b/services/pp-auth-demo/vite.config.ts new file mode 100644 index 000000000..deb417265 --- /dev/null +++ b/services/pp-auth-demo/vite.config.ts @@ -0,0 +1,7 @@ +import tailwindcss from "@tailwindcss/vite"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], +}); diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json new file mode 100644 index 000000000..c6b02a6b8 --- /dev/null +++ b/services/ppa/config/certification-framework.json @@ -0,0 +1,401 @@ +{ + "$comment": "The PPA application certification matrix, transcribed from 'Post-Platforms Certification Framework — Application Certification Framework Concept v2'. The document calls its quantitative thresholds provisional policy parameters, so this file is versioned and every assessment records the version that judged it. Each dimension lists its distinct requirement texts; `level` is the highest certification level that requirement satisfies, so a repeated requirement collapses to one option. `source: derived` means the app answers the row itself, from the release proof, the actors’ binding documents or signed eReputation references.", + "frameworkVersion": "3", + "levels": [ + { + "id": "L0", + "label": "W3DS-compatible experimental application" + }, + { + "id": "L1", + "label": "Identified responsible person and basic functional review" + }, + { + "id": "L2", + "label": "Source-code review and direct assessment" + }, + { + "id": "L3", + "label": "Proven development history and reputation" + }, + { + "id": "L4", + "label": "Established operational reputation and independent review" + }, + { + "id": "L5", + "label": "High-assurance industrial application" + } + ], + "identityFloor": { + "L0": "IAL2", + "L1": "IAL3", + "L2": "IAL3", + "L3": "IAL4", + "L4": "IAL4", + "L5": "IAL4" + }, + "dimensions": [ + { + "id": "w3ds-compatibility", + "label": "W3DS compatibility", + "source": "derived", + "options": [ + { + "level": -1, + "label": "Does not authenticate a user through W3DS or reach an eVault" + }, + { + "level": 5, + "label": "Authenticates a user through W3DS and interacts with an eVault" + } + ] + }, + { + "id": "functional-review", + "label": "Functional review", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "Not run — nobody has launched it" + }, + { + "level": 1, + "label": "Launched, and it broadly does what it claims" + }, + { + "level": 3, + "label": "Main flows exercised against the stated functionality and the areas it asks for" + }, + { + "level": 4, + "label": "Edge cases, failure handling and data flows exercised as well" + }, + { + "level": 5, + "label": "Reviewed against a written specification, with the results recorded" + } + ] + }, + { + "id": "identity-assurance", + "label": "Responsible actors — minimum identity assurance", + "source": "derived", + "options": [ + { + "level": -1, + "label": "IAL1 — at least one responsible person is anonymous" + }, + { + "level": 0, + "label": "IAL2 — every responsible person is vouched for by someone already identified" + }, + { + "level": 2, + "label": "IAL3 — every responsible person is verified against a passport or equivalent eID" + }, + { + "level": 5, + "label": "IAL4 — every responsible person is passport-verified and attested by three others who are" + } + ] + }, + { + "id": "roles", + "label": "Developer / deployer roles", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "One responsible person is recorded" + }, + { + "level": 1, + "label": "Developer and deployer are the same person" + }, + { + "level": 2, + "label": "They are different people, and each role is recorded" + }, + { + "level": 4, + "label": "Every key actor is known and named" + }, + { + "level": 5, + "label": "Every role from author to operator is named and attributable" + } + ] + }, + { + "id": "development-method", + "label": "AI / development method", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "Not asked — how the software was built is unrecorded" + }, + { + "level": 1, + "label": "Declared — the team stated whether it was conventional, AI-assisted, vibe-coded or mixed" + }, + { + "level": 2, + "label": "The declaration is recorded in this assessment, for this release" + }, + { + "level": 4, + "label": "Traceable — which parts were AI-generated can be identified from the commit history" + }, + { + "level": 5, + "label": "Auditable — AI involvement is documented per change and can be checked independently" + } + ] + }, + { + "id": "source-available", + "label": "Source code available", + "source": "derived", + "options": [ + { + "level": -1, + "label": "Source is not available to PPA" + }, + { + "level": 5, + "label": "Source is available to PPA" + } + ] + }, + { + "id": "code-review", + "label": "PPA code review", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "Nobody has read the code" + }, + { + "level": 1, + "label": "Automated scan only — tooling run to catch gross errors and obvious risks" + }, + { + "level": 3, + "label": "Manually reviewed this release, looking for problems and inconsistencies" + }, + { + "level": 4, + "label": "Manually reviewed the release, its dependencies, data handling and sensitive paths" + }, + { + "level": 5, + "label": "Manually reviewed by two people independently, with findings tracked to resolution" + } + ] + }, + { + "id": "interview", + "label": "Interview", + "source": "reviewer", + "options": [ + { + "level": 1, + "label": "Nobody has been interviewed" + }, + { + "level": 2, + "label": "Owner, lead developer and deployer/operator interviewed, where these are different people" + }, + { + "level": 3, + "label": "Every key responsible actor interviewed" + }, + { + "level": 4, + "label": "Key actors and the relevant technical leads interviewed" + }, + { + "level": 5, + "label": "Every accountable lead interviewed, including security and key custody" + } + ] + }, + { + "id": "provenance", + "label": "Development provenance / commits", + "source": "derived", + "options": [ + { + "level": -1, + "label": "No commit history recorded" + }, + { + "level": 1, + "label": "Commit history recorded from the start" + }, + { + "level": 2, + "label": "The commit history has been looked at" + }, + { + "level": 3, + "label": "Required and reviewed — contributions traced to named people" + }, + { + "level": 4, + "label": "Extended review of who changed what, and when" + }, + { + "level": 5, + "label": "Fully auditable — every change attributable to an identified contributor" + } + ] + }, + { + "id": "actor-reputation", + "label": "Responsible actors — eReputation / professional references", + "source": "derived", + "options": [ + { + "level": 1, + "label": "References recorded; no minimum met" + }, + { + "level": 2, + "label": "Each responsible actor holds 1 or 2 signed references" + }, + { + "level": 3, + "label": "Each responsible actor holds at least 3 signed professional references" + }, + { + "level": 4, + "label": "At least 5, including 2 from recognised professionals" + }, + { + "level": 5, + "label": "At least 10 from high-reputation professionals or organisations" + } + ] + }, + { + "id": "track-record", + "label": "Application track record / authenticated eReputation", + "source": "derived", + "options": [ + { + "level": 2, + "label": "No prior track record required at this level" + }, + { + "level": 3, + "label": "A prior Level 2+ application with positive standing, and around 50 authenticated signals" + }, + { + "level": 4, + "label": "Established reputation from previous applications, and around 1,000 authenticated signals" + }, + { + "level": 5, + "label": "An extensive portfolio, and around 10,000 authenticated signals" + } + ] + }, + { + "id": "independent-review", + "label": "Independent professional review", + "source": "derived", + "options": [ + { + "level": 3, + "label": "No independent review" + }, + { + "level": 4, + "label": "One signed review by a specialist independent of the applicant, stating what was reviewed and found" + }, + { + "level": 5, + "label": "Several such reviews, from independent parties" + } + ] + }, + { + "id": "external-certification", + "label": "External certification", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "Not relevant — none claimed, and none needed here" + }, + { + "level": 1, + "label": "Claimed but unverified — mentioned, not checked" + }, + { + "level": 2, + "label": "A certificate from another body exists and has been looked at" + }, + { + "level": 3, + "label": "Verified as genuine, and accepted in place of repeating that work" + }, + { + "level": 5, + "label": "Verified, and the issuing body’s own standing weighed in the judgement" + } + ] + }, + { + "id": "key-assurance", + "label": "Key / infrastructure assurance", + "source": "reviewer", + "options": [ + { + "level": 0, + "label": "Nothing asked about hosting, secrets or key custody" + }, + { + "level": 1, + "label": "The team described where it runs and who can administer it" + }, + { + "level": 2, + "label": "Secrets and deployment keys kept apart from the source" + }, + { + "level": 3, + "label": "Admin access, secret storage and change control checked where they matter" + }, + { + "level": 4, + "label": "Access control, key custody, backup and change control all reviewed and sound" + }, + { + "level": 5, + "label": "Signing and deployment keys held in hardware or an HSM" + } + ] + }, + { + "id": "findings-recorded", + "label": "Findings and signed certificate in eVault", + "source": "derived", + "options": [ + { + "level": 0, + "label": "Only a minimal statement is recorded" + }, + { + "level": 5, + "label": "Full findings published with the certificate" + } + ] + } + ] +} diff --git a/services/ppa/package.json b/services/ppa/package.json index dddb5b42f..569d01efa 100644 --- a/services/ppa/package.json +++ b/services/ppa/package.json @@ -10,7 +10,8 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "generate-jwk": "node scripts/generate-ppa-jwk.cjs", - "seed:submission": "tsx scripts/seed-submission.ts" + "seed:submission": "tsx scripts/seed-submission.ts", + "test": "vitest run" }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.12", @@ -23,7 +24,8 @@ "tailwindcss": "^4.0.0", "tsx": "^4.19.2", "typescript": "^5.0.0", - "vite": "^6.2.6" + "vite": "^6.2.6", + "vitest": "^3.2.4" }, "dependencies": { "axios": "^1.12.2", diff --git a/services/ppa/src/lib/AssessmentMatrix.svelte b/services/ppa/src/lib/AssessmentMatrix.svelte new file mode 100644 index 000000000..c605375fe --- /dev/null +++ b/services/ppa/src/lib/AssessmentMatrix.svelte @@ -0,0 +1,183 @@ + + +
+

Assessment

+

+ Every dimension counts. The level is their geometric mean, so a weak row + pulls the result down far more than an average would, without one row + pinning the rest. Framework v{framework.frameworkVersion}. +

+ + +
+
+

Supported level

+

+ {result.level ?? "None yet"} +

+
+
+ {#if answeredCount < reviewerRows.length} +

+ {reviewerRows.length - answeredCount} of {reviewerRows.length} + judgements still to make. +

+

+ An unanswered dimension counts as no evidence. +

+ {:else if result.blocked && limitingLabel} +

+ {limitingLabel} + is unanswered or fails outright, so no level can be awarded. +

+ {:else if limitingLabel} +

+ Weakest row: + {limitingLabel}. +

+

+ It drags the result hardest, but every row moves it. +

+ {/if} +
+
+ + +

+ Established from evidence +

+
    + {#each derivedRows as dimension (dimension.id)} + {@const answer = derivedById.get(dimension.id)} +
  • + {dimension.label} + {levelLabel(levelOf.get(dimension.id))} + + {dimension.options[answer?.option ?? 0]?.label} + {#if answer} — {answer.evidence}{/if} + +
  • + {/each} +
+ + +

+ Your assessment +

+
+ {#each reviewerRows as dimension (dimension.id)} +
+ + {dimension.label} + {#if chosen[dimension.id] !== undefined} + + {levelLabel(levelOf.get(dimension.id))} + + {/if} + + +
+ {#each dimension.options as option, index (option.label)} + + {/each} +
+
+ {/each} +
+
diff --git a/services/ppa/src/lib/AssessmentResult.svelte b/services/ppa/src/lib/AssessmentResult.svelte new file mode 100644 index 000000000..6acf99e6c --- /dev/null +++ b/services/ppa/src/lib/AssessmentResult.svelte @@ -0,0 +1,117 @@ + + +
+

Calculation

+

+ The geometric mean of every dimension, so a weak row pulls the result + down far more than an average would, without one row pinning the rest. +

+ +
+
+

Geometric mean

+

+ {result.blocked ? "—" : result.score.toFixed(2)} +

+
+
+ {#if identityCapped && result.scoredLevel} +
+

Evidence supports

+

+ {result.scoredLevel} +

+
+
+ {/if} +
+

Computed level

+

+ {result.level ?? "None"} +

+
+
+ {#if result.blocked} +

+ {limitingLabel + ? `${limitingLabel} is unanswered or fails outright.` + : "A dimension is unanswered or fails outright."} +

+ {:else if identityCapped} +

+ The assessment supports {result.scoredLevel}, but + {result.scoredLevel} needs every responsible person verified + to {framework.identityFloor[result.scoredLevel ?? "L0"]}. The + weakest is {minimumIal}, so this is held at {result.level}. +

+ {:else if limitingLabel} +

+ Weakest dimension: {limitingLabel}. +

+ {/if} +
+
+ +
+ + Show every dimension + +
    + {#each ranked as dimension (dimension.id)} +
  • + + {dimension.level < 0 ? "—" : ACCESS_LEVELS[dimension.level]} + + + {labels.get(dimension.id) ?? dimension.id} + +
  • + {/each} +
+
+
diff --git a/services/ppa/src/lib/IdentityPanel.svelte b/services/ppa/src/lib/IdentityPanel.svelte new file mode 100644 index 000000000..615272e01 --- /dev/null +++ b/services/ppa/src/lib/IdentityPanel.svelte @@ -0,0 +1,95 @@ + + +
+
+

Responsible actors

+ Weakest: {minimumIal} +
+

+ Worked out from each person's binding documents. The weakest of them is + what caps the level, so one unidentified actor holds back the release. +

+ + {#if required && minimumIal < required} +

+ That level needs {required}. As it stands the release cannot go above + what {minimumIal} supports. +

+ {/if} + +
    + {#each actors as actor (actor.ename)} +
  • +
    + {actor.ial} + {ROLES[actor.role] ?? actor.role} + {actor.ename} +
    +

    + {MEANING[actor.ial]} + + · {actor.idDocuments} eID · {actor.attestations} attestation{actor.attestations === 1 ? "" : "s"} + {#if actor.attestations > 0}({actor.verifiedAttesters} from verified people){/if} + +

    + {#if gap(actor)} +

    {gap(actor)}

    + {/if} +
  • + {/each} +
+
diff --git a/services/ppa/src/lib/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts new file mode 100644 index 000000000..015c3c011 --- /dev/null +++ b/services/ppa/src/lib/levels.spec.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import frameworkJson from "../../config/certification-framework.json"; +import { + computeLevel, + type DimensionAnswer, + type Framework, +} from "./levels"; + +const framework = frameworkJson as unknown as Framework; + +/** + * The cheapest answer for a dimension that still satisfies `level`. Some rows + * (W3DS compatibility, source availability) have a single option that + * satisfies every level because the framework requires them throughout, so + * "the highest option at or below N" is the wrong way to model a platform + * sitting at level N. + */ +function pick(id: string, level: number): DimensionAnswer { + const dimension = framework.dimensions.find((d) => d.id === id); + if (!dimension) throw new Error(`no dimension ${id}`); + let best = Infinity; + let option = -1; + dimension.options.forEach((o, i) => { + if (o.level >= level && o.level < best) { + best = o.level; + option = i; + } + }); + if (option === -1) throw new Error(`${id} cannot reach level ${level}`); + return { id, option }; +} + +const allAt = (level: number) => framework.dimensions.map((d) => pick(d.id, level)); + +describe("computeLevel", () => { + it("awards the level every dimension supports", () => { + expect(computeLevel(framework, allAt(5), "IAL4").level).toBe("L5"); + expect(computeLevel(framework, allAt(3), "IAL4").level).toBe("L3"); + expect(computeLevel(framework, allAt(0), "IAL2").level).toBe("L0"); + }); + + it("lets one weak dimension drag the result without pinning it", () => { + const answers = [ + ...allAt(5).filter((a) => a.id !== "code-review"), + pick("code-review", 1), + ]; + const result = computeLevel(framework, answers, "IAL4"); + // Geometric mean of fifteen 5s and a single 1 — well below 5, but not + // dragged all the way down to it the way a strict minimum would. + expect(result.score).toBeGreaterThan(1); + expect(result.score).toBeLessThan(5); + expect(result.level).toBe("L4"); + expect(result.limiting).toBe("code-review"); + }); + + it("punishes a weakness far harder than an arithmetic mean would", () => { + const answers = [ + ...allAt(5).filter((a) => a.id !== "code-review"), + pick("code-review", 1), + ]; + const { score } = computeLevel(framework, answers, "IAL4"); + const arithmetic = + (5 * (framework.dimensions.length - 1) + 1) / framework.dimensions.length; + expect(score).toBeLessThan(arithmetic); + }); + + it("does not let one L0 row collapse an otherwise strong assessment", () => { + // L0 is a real answer on this scale, so a plain geometric mean would + // multiply the whole product by zero and award L0 regardless. + const answers = [ + ...allAt(5).filter((a) => a.id !== "functional-review"), + pick("functional-review", 0), + ]; + const result = computeLevel(framework, answers, "IAL4"); + expect(result.score).toBeGreaterThan(3); + expect(result.level).toBe("L4"); + expect(result.limiting).toBe("functional-review"); + }); + + it("still weighs several weak rows heavily", () => { + // Two rows at L0 and three at L1, against a spread up to L5. + const weak = ["functional-review", "code-review"]; + const weaker = ["provenance", "actor-reputation", "key-assurance"]; + const answers = [ + ...allAt(5).filter( + (a) => !weak.includes(a.id) && !weaker.includes(a.id), + ), + ...weak.map((id) => pick(id, 0)), + ...weaker.map((id) => pick(id, 1)), + ]; + const result = computeLevel(framework, answers, "IAL4"); + const arithmetic = + result.perDimension.reduce((a, d) => a + d.level, 0) / + result.perDimension.length; + expect(result.score).toBeLessThan(arithmetic); + expect(result.level).toBe("L2"); + }); + + it("caps at the identity floor even when every dimension is perfect", () => { + // L3+ requires IAL4; IAL3 can therefore support no more than L2. + const result = computeLevel(framework, allAt(5), "IAL3"); + expect(result.level).toBe("L2"); + expect(result.limiting).toBe("identity"); + }); + + it("still reports what the evidence alone supported when capped", () => { + // Without this the reviewer sees a mean of 5 next to an award of L2 and + // reasonably reads it as a bug rather than as the identity floor. + const result = computeLevel(framework, allAt(5), "IAL3"); + + expect(result.scoredLevel).toBe("L5"); + expect(result.level).toBe("L2"); + }); + + it("reports the same level twice when nothing capped it", () => { + const result = computeLevel(framework, allAt(3), "IAL4"); + + expect(result.scoredLevel).toBe("L3"); + expect(result.level).toBe("L3"); + }); + + it("refuses any level for an anonymous responsible party", () => { + expect(computeLevel(framework, allAt(5), "IAL1").level).toBeNull(); + }); + + it("treats an unanswered dimension as no evidence", () => { + const answers = allAt(5).filter((a) => a.id !== "interview"); + const result = computeLevel(framework, answers, "IAL4"); + expect(result.level).toBeNull(); + expect(result.blocked).toBe(true); + expect(result.limiting).toBe("interview"); + }); + + it("reports every dimension's satisfied level for display", () => { + const result = computeLevel(framework, allAt(2), "IAL3"); + expect(result.perDimension).toHaveLength(framework.dimensions.length); + expect(result.perDimension.every((d) => d.level >= 2)).toBe(true); + }); +}); diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts index f256c8198..6c0294800 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -1,15 +1,188 @@ /** - * Shared between the decision form and the server that signs it, so this - * lives outside $lib/server — SvelteKit refuses to bundle server-only modules - * into a component. + * Certification and identity vocabularies, and the pure rules that combine + * them. Shared between server code and components, so this lives outside + * $lib/server — SvelteKit refuses to pull a server-only module into a + * component. + * + * Both lists are published by the ontology service as `Certification Level` + * and `Identity Assurance Level`; these are the same values, kept here so the + * form and the level computation do not need a network round trip. */ -export const ACCESS_LEVELS = ["L1", "L2", "L3", "L4", "L5"] as const; +export const ACCESS_LEVELS = ["L0", "L1", "L2", "L3", "L4", "L5"] as const; export type AccessLevel = (typeof ACCESS_LEVELS)[number]; +export const IDENTITY_LEVELS = ["IAL1", "IAL2", "IAL3", "IAL4"] as const; +export type IdentityLevel = (typeof IDENTITY_LEVELS)[number]; + export function isAccessLevel(value: unknown): value is AccessLevel { return ( typeof value === "string" && (ACCESS_LEVELS as readonly string[]).includes(value) ); } + +export function isIdentityLevel(value: unknown): value is IdentityLevel { + return ( + typeof value === "string" && + (IDENTITY_LEVELS as readonly string[]).includes(value) + ); +} + +/** "L3" -> 3, and back. Levels are ordered and cumulative. */ +export function levelIndex(level: AccessLevel): number { + return ACCESS_LEVELS.indexOf(level); +} + +export function levelFromIndex(index: number): AccessLevel | null { + return index >= 0 && index < ACCESS_LEVELS.length + ? ACCESS_LEVELS[index] + : null; +} + +export function identityIndex(level: IdentityLevel): number { + return IDENTITY_LEVELS.indexOf(level); +} + +// --------------------------------------------------------------------------- + +export interface FrameworkOption { + /** Highest certification level this requirement satisfies; -1 blocks it. */ + level: number; + label: string; +} + +export interface FrameworkDimension { + id: string; + label: string; + source: "derived" | "reviewer"; + options: FrameworkOption[]; +} + +export interface Framework { + frameworkVersion: string; + levels: { id: AccessLevel; label: string }[]; + identityFloor: Record; + dimensions: FrameworkDimension[]; +} + +/** One reviewer or derived answer: the option chosen for a dimension. */ +export interface DimensionAnswer { + id: string; + /** Index into the dimension's options. */ + option: number; + note?: string | null; +} + +export interface ComputedLevel { + level: AccessLevel | null; + /** The geometric mean itself, before flooring — shown in the calculation. */ + score: number; + /** + * The level the evidence alone supports, before the identity floor is + * applied. Shown alongside `level` so a cap reads as a cap rather than as + * an arithmetic mistake. + */ + scoredLevel: AccessLevel | null; + /** Weakest dimension, or "identity" when the IAL floor is what capped it. */ + limiting: string | null; + /** True when a dimension fails outright, so no level can be awarded. */ + blocked: boolean; + /** Per-dimension satisfied level, for display. */ + perDimension: { id: string; level: number }[]; +} + +/** + * The level a set of answers supports: the geometric mean of the per-dimension + * levels, floored. + * + * A geometric mean keeps the framework's point that "a strong result in one + * dimension does not erase a weakness in another" — a low row drags the result + * far more than an arithmetic mean would — without letting one middling row pin + * an otherwise strong release to its own value the way a strict minimum did. + * + * The mean is taken over level + 1 and shifted back afterwards. L0 is a real, + * expected answer on this scale (a code review nobody performed is L0), and a + * plain geometric mean multiplies by zero, so a single such row would collapse + * the score to zero no matter how strong the other fifteen were. + * + * The identity floor is applied afterwards as a hard cap, because the framework + * states it as a requirement rather than a contribution: L0 needs IAL2, L1–L2 + * need IAL3, L3–L5 need IAL4. + * + * Returns null when nothing is supportable — an unanswered dimension, an answer + * that fails outright, or an anonymous responsible party. + */ +export function computeLevel( + framework: Framework, + answers: DimensionAnswer[], + minimumIal: IdentityLevel, +): ComputedLevel { + const byId = new Map(answers.map((a) => [a.id, a])); + const perDimension: { id: string; level: number }[] = []; + + let lowest = Number.POSITIVE_INFINITY; + let limiting: string | null = null; + let blocked = false; + + for (const dimension of framework.dimensions) { + const answer = byId.get(dimension.id); + const option = + answer === undefined ? undefined : dimension.options[answer.option]; + // An unanswered dimension is not evidence of anything. + const satisfied = option ? option.level : -1; + perDimension.push({ id: dimension.id, level: satisfied }); + if (satisfied < lowest) { + lowest = satisfied; + limiting = dimension.id; + } + if (satisfied < 0) blocked = true; + } + + if (blocked || perDimension.length === 0) { + return { + level: null, + score: 0, + scoredLevel: null, + limiting, + blocked: true, + perDimension, + }; + } + + // Geometric mean over level + 1, shifted back, so a legitimate L0 row + // weighs heavily without annihilating the product. + const score = + Math.exp( + perDimension.reduce((acc, d) => acc + Math.log(d.level + 1), 0) / + perDimension.length, + ) - 1; + + // exp(mean(ln 6)) - 1 lands a hair under 5, so floor alone would award L4 + // for a flawless assessment. Nudge past the float error before flooring. + let index = Math.floor(score + 1e-9); + const scoredLevel = levelFromIndex(index); + + // The identity floor: the highest level whose required IAL is met. + let identityCap = -1; + for (let i = ACCESS_LEVELS.length - 1; i >= 0; i--) { + const required = framework.identityFloor[ACCESS_LEVELS[i]]; + if (identityIndex(minimumIal) >= identityIndex(required)) { + identityCap = i; + break; + } + } + if (identityCap < index) { + index = identityCap; + limiting = "identity"; + } + + return { + level: levelFromIndex(index), + score, + scoredLevel, + limiting, + blocked: false, + perDimension, + }; +} diff --git a/services/ppa/src/lib/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts index 5b10bc20e..5a8cbb274 100644 --- a/services/ppa/src/lib/server/aaas.ts +++ b/services/ppa/src/lib/server/aaas.ts @@ -610,3 +610,4 @@ export async function currentAccreditations(): Promise; + signatures: Array<{ signer: string; signature: string; timestamp: string }>; +} + +interface BindingDocumentsResponse { + bindingDocuments: { + edges: Array<{ + node: { id: string; parsed: Record | null }; + }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; +} + interface CreateResponse { createMetaEnvelope: { metaEnvelope: { id: string } | null; @@ -83,6 +119,116 @@ async function resolveEVaultUrl(ename: string): Promise { return resolved; } +/** + * Every binding document held by one eName — the identity evidence behind an + * accountable actor. Reads need a platform token because a person's binding + * documents are ACL'd to them; deployment documents are the only public ones. + * + * Mirrors platforms/enotary/src/lib/server/evault.ts, which reads the same + * documents to name the counterparty of a social connection. + */ +export async function fetchBindingDocuments( + ename: string, +): Promise { + const normalized = normalizeEName(ename); + const [baseUrl, token] = await Promise.all([ + resolveEVaultUrl(normalized), + getPlatformToken(), + ]); + + const client = new GraphQLClient(new URL("/graphql", baseUrl).toString(), { + headers: { Authorization: `Bearer ${token}`, "X-ENAME": normalized }, + }); + + const out: BindingDocument[] = []; + let after: string | null = null; + do { + const res: BindingDocumentsResponse = + await client.request( + BINDING_DOCUMENTS_QUERY, + { first: 100, after: after ?? undefined }, + ); + for (const edge of res.bindingDocuments.edges) { + const parsed = edge.node.parsed; + if (!parsed || typeof parsed !== "object") continue; + const { subject, type, data, signatures } = parsed as Record< + string, + unknown + >; + if ( + typeof subject !== "string" || + typeof type !== "string" || + typeof data !== "object" || + data === null || + !Array.isArray(signatures) + ) { + continue; + } + out.push({ + id: edge.node.id, + subject, + type, + data: data as Record, + signatures: signatures as BindingDocument["signatures"], + }); + } + after = res.bindingDocuments.pageInfo.hasNextPage + ? res.bindingDocuments.pageInfo.endCursor + : null; + } while (after !== null); + + return out; +} + +/** Shared writer: both records go to the reviewed platform's own eVault. */ +async function storeForPlatform( + platformEName: string, + ontology: string, + payload: unknown, +): Promise { + const ename = normalizeEName(platformEName); + const [baseUrl, token] = await Promise.all([ + resolveEVaultUrl(ename), + getPlatformToken(), + ]); + + const client = new GraphQLClient(new URL("/graphql", baseUrl).toString(), { + headers: { Authorization: `Bearer ${token}`, "X-ENAME": ename }, + }); + + const response = await client.request( + CREATE_META_ENVELOPE, + { input: { ontology, payload, acl: ["*"] } }, + ); + + const errors = response.createMetaEnvelope.errors ?? []; + if (errors.length > 0) { + throw new Error( + `eVault rejected the record: ${errors + .map((e) => `${e.field ?? "?"}: ${e.message}`) + .join("; ")}`, + ); + } + const id = response.createMetaEnvelope.metaEnvelope?.id; + if (!id) throw new Error("eVault returned no MetaEnvelope id"); + return id; +} + +/** + * Writes the findings behind a decision. Public, like the certificate, because + * the framework's point is that a later reader can inspect the evidence rather + * than trusting the headline level. + */ +export async function storeAssessment( + assessment: Assessment, +): Promise { + return storeForPlatform( + assessment.platformEName, + PLATFORM_ASSESSMENT_ONTOLOGY, + assessment, + ); +} + /** * Writes one decision into the reviewed platform's eVault, with a public ACL * so the platform, the marketplace and anyone else can read and verify it. diff --git a/services/ppa/src/lib/server/framework.ts b/services/ppa/src/lib/server/framework.ts new file mode 100644 index 000000000..1a02d25d7 --- /dev/null +++ b/services/ppa/src/lib/server/framework.ts @@ -0,0 +1,169 @@ +/** + * The certification framework, and the rows the app can answer for itself. + * + * The matrix is policy with provisional thresholds, so it is loaded from a + * versioned file rather than hard-coded, and every assessment records the + * version that judged it. + */ + +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import type { ActorIdentity } from "./identity"; +import type { Submission } from "./ontology"; +import type { ReputationEvidence } from "./reputation"; +import type { Framework, IdentityLevel } from "$lib/levels"; + +const CACHE = Symbol.for("ppa.framework"); +const store = globalThis as typeof globalThis & { + [CACHE]?: { mtimeMs: number; framework: Framework }; +}; + +/** + * Reloads when the file changes rather than caching for the life of the + * process. This is editable policy, and a cache that outlives an edit means a + * reviewer changes the matrix, sees no difference, and has no way to tell + * whether the file or the app is wrong. + */ +export async function loadFramework(): Promise { + // cwd is services/ppa under both `vite dev` and `node build/index.js`. + const file = path.resolve(process.cwd(), "config/certification-framework.json"); + const cached = store[CACHE]; + try { + const { mtimeMs } = await stat(file); + if (cached && cached.mtimeMs === mtimeMs) return cached.framework; + const framework = JSON.parse(await readFile(file, "utf8")) as Framework; + store[CACHE] = { mtimeMs, framework }; + return framework; + } catch (error) { + // An unreadable file must not blank the matrix mid-review. + if (cached) { + console.error("[ppa/framework] could not reload the matrix:", error); + return cached.framework; + } + throw error; + } +} + +/** Index of the option carrying a given level, for building derived answers. */ +function optionAtLevel( + framework: Framework, + dimensionId: string, + level: number, +): number { + const dimension = framework.dimensions.find((d) => d.id === dimensionId); + if (!dimension) return 0; + const index = dimension.options.findIndex((o) => o.level === level); + return index >= 0 ? index : 0; +} + +export interface DerivedAnswer { + id: string; + option: number; + /** Why the app answered this way, shown instead of asking the reviewer. */ + evidence: string; +} + +/** + * The rows the app can answer from evidence it has already verified. Everything + * else is a reviewer judgement and is asked, because guessing at it would make + * a certificate claim more than was actually checked. + */ +export function deriveAnswers( + framework: Framework, + context: { + submission: Submission; + minimumIal: IdentityLevel; + actors: ActorIdentity[]; + reputation: ReputationEvidence; + }, +): DerivedAnswer[] { + const { submission, minimumIal, actors, reputation } = context; + const at = (id: string, level: number) => optionAtLevel(framework, id, level); + + // The submission is only in the queue at all because its release statement + // verified against a Registry-backed key binding. + const answers: DerivedAnswer[] = [ + { + id: "w3ds-compatibility", + option: at("w3ds-compatibility", 5), + evidence: "The signed release statement verified against the Registry.", + }, + ]; + + const repository = submission.submissionProof.statement.repository; + answers.push({ + id: "source-available", + option: at("source-available", repository ? 5 : -1), + evidence: repository + ? `Published from ${repository}.` + : "No repository named in the release statement.", + }); + + const identityLevels: Record = { + IAL1: -1, + IAL2: 0, + IAL3: 2, + IAL4: 5, + }; + answers.push({ + id: "identity-assurance", + option: at("identity-assurance", identityLevels[minimumIal]), + evidence: + actors.length === 0 + ? "No accountable actor is named on the release." + : `Weakest of ${actors.length} accountable actor${actors.length === 1 ? "" : "s"}: ${minimumIal}.`, + }); + + const commit = submission.submissionProof.statement.manifestCommitId; + answers.push({ + id: "provenance", + option: at("provenance", commit ? 1 : -1), + evidence: commit + ? `Manifest commit ${commit.slice(0, 12)} recorded, with ${submission.authorEnames.length} named author${submission.authorEnames.length === 1 ? "" : "s"}. Reviewing that history is a judgement above this row.` + : "No manifest commit recorded.", + }); + + // The framework's reputation thresholds are counts, so they are counted. + // Signed references are public, which is what makes this evidence rather + // than an assertion. + const refs = reputation.minimumActorReferences; + const actorLevel = refs >= 10 ? 5 : refs >= 5 ? 4 : refs >= 3 ? 3 : refs >= 1 ? 2 : 1; + answers.push({ + id: "actor-reputation", + option: at("actor-reputation", actorLevel), + evidence: reputation.error + ? `eReputation could not be reached (${reputation.error}); counted as none.` + : actors.length === 0 + ? "No accountable actor to hold references." + : `Weakest actor holds ${refs} signed reference${refs === 1 ? "" : "s"}.`, + }); + + const platformRefs = reputation.platformReferences; + const trackLevel = + platformRefs >= 10000 ? 5 : platformRefs >= 1000 ? 4 : platformRefs >= 50 ? 3 : 2; + answers.push({ + id: "track-record", + option: at("track-record", trackLevel), + evidence: reputation.error + ? `eReputation could not be reached (${reputation.error}); counted as none.` + : `${platformRefs} authenticated signal${platformRefs === 1 ? "" : "s"} for this platform.`, + }); + + const independent = reputation.independentReviews; + const reviewLevel = independent >= 2 ? 5 : independent === 1 ? 4 : 3; + answers.push({ + id: "independent-review", + option: at("independent-review", reviewLevel), + evidence: reputation.error + ? `eReputation could not be reached (${reputation.error}); counted as none.` + : `${independent} signed review${independent === 1 ? "" : "s"} from outside the accountable actors.`, + }); + + answers.push({ + id: "findings-recorded", + option: at("findings-recorded", 5), + evidence: "This assessment is published with the certificate.", + }); + + return answers; +} diff --git a/services/ppa/src/lib/server/identity.spec.ts b/services/ppa/src/lib/server/identity.spec.ts new file mode 100644 index 000000000..8c980168a --- /dev/null +++ b/services/ppa/src/lib/server/identity.spec.ts @@ -0,0 +1,164 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The eVault read is the only I/O; everything else is pure classification. + * Stubbed with a plain closure rather than vi.fn(): a mock records the result + * of every call, and a recorded throw is surfaced as a test failure even when + * the code under test catches it. + */ +let impl: (ename: string) => unknown = () => []; +let calls: string[] = []; + +vi.mock("./evault", () => ({ + fetchBindingDocuments: (ename: string) => { + calls.push(ename); + return impl(ename); + }, +})); + +const { deriveIdentity, minimumIdentity, accountableActors } = await import( + "./identity" +); + +const idDoc = (subject: string) => ({ + id: `id-${subject}`, + subject, + type: "id_document", + data: { vendor: "veriff", reference: "abc", name: "A Person" }, + signatures: [ + { signer: subject, signature: "s", timestamp: "2026-01-01T00:00:00Z" }, + ], +}); + +const connection = (a: string, b: string) => ({ + id: `sc-${a}-${b}`, + subject: a, + type: "social_connection", + data: { name: "A Person", parties: [a, b], relation_description: "colleague" }, + signatures: [ + { signer: a, signature: "s1", timestamp: "2026-01-01T00:00:00Z" }, + { signer: b, signature: "s2", timestamp: "2026-01-01T00:00:00Z" }, + ], +}); + +describe("deriveIdentity", () => { + beforeEach(() => { + impl = () => []; + calls = []; + }); + + it("is IAL1 with no evidence", async () => { + expect((await deriveIdentity("@nobody")).ial).toBe("IAL1"); + }); + + it("is IAL2 when attested by someone, without an eID", async () => { + impl = (e) => (e === "@a" ? [connection("@a", "@b")] : []); + const result = await deriveIdentity("@a"); + expect(result.ial).toBe("IAL2"); + expect(result.attestations).toBe(1); + }); + + it("is IAL3 with a verified eID", async () => { + impl = (e) => (e === "@a" ? [idDoc("@a")] : []); + expect((await deriveIdentity("@a")).ial).toBe("IAL3"); + }); + + it("needs three passport-identified attesters for IAL4", async () => { + const attesters = ["@x", "@y", "@z"]; + impl = (e) => { + if (e === "@a") { + return [idDoc("@a"), ...attesters.map((p) => connection("@a", p))]; + } + return attesters.includes(e) ? [idDoc(e)] : []; + }; + const result = await deriveIdentity("@a"); + expect(result.ial).toBe("IAL4"); + expect(result.verifiedAttesters).toBe(3); + }); + + it("does not reach IAL4 when the attesters are themselves unverified", async () => { + impl = (e) => + e === "@a" + ? [ + idDoc("@a"), + connection("@a", "@x"), + connection("@a", "@y"), + connection("@a", "@z"), + ] + : []; + const result = await deriveIdentity("@a"); + expect(result.ial).toBe("IAL3"); + expect(result.verifiedAttesters).toBe(0); + }); + + it("ignores a social connection only one party signed", async () => { + const half = { + ...connection("@a", "@b"), + signatures: [{ signer: "@a", signature: "s", timestamp: "t" }], + }; + impl = (e) => (e === "@a" ? [half] : []); + expect((await deriveIdentity("@a")).ial).toBe("IAL1"); + }); + + it("terminates on a mutual attestation", async () => { + impl = (e) => { + if (e === "@a") return [idDoc("@a"), connection("@a", "@b")]; + if (e === "@b") return [idDoc("@b"), connection("@b", "@a")]; + return []; + }; + const result = await deriveIdentity("@a"); + expect(result.ial).toBe("IAL3"); + // @a, then @b one level deep — and no further. + expect(calls).toEqual(["@a", "@b"]); + }); + + it("stays IAL1 and reports why when the vault cannot be read", async () => { + impl = () => { + throw new Error("resolve failed"); + }; + const result = await deriveIdentity("@a"); + expect(result.ial).toBe("IAL1"); + expect(result.error).toContain("resolve failed"); + }); +}); + +describe("minimumIdentity", () => { + it("takes the weakest actor", () => { + expect( + minimumIdentity([ + { + ename: "@a", + ial: "IAL4", + idDocuments: 1, + attestations: 3, + verifiedAttesters: 3, + }, + { + ename: "@b", + ial: "IAL2", + idDocuments: 0, + attestations: 1, + verifiedAttesters: 0, + }, + ]), + ).toBe("IAL2"); + }); + + it("is IAL1 when there are no actors at all", () => { + expect(minimumIdentity([])).toBe("IAL1"); + }); +}); + +describe("accountableActors", () => { + it("includes the release signer and de-duplicates", () => { + expect( + accountableActors({ + authorEnames: ["@a", "@signer", "@a"], + submissionProof: { statement: { signerEName: "@signer" } }, + }), + ).toEqual([ + { ename: "@signer", role: "releaseSigner" }, + { ename: "@a", role: "author" }, + ]); + }); +}); diff --git a/services/ppa/src/lib/server/identity.ts b/services/ppa/src/lib/server/identity.ts new file mode 100644 index 000000000..89d5d3655 --- /dev/null +++ b/services/ppa/src/lib/server/identity.ts @@ -0,0 +1,168 @@ +/** + * Identity assurance for the people accountable for a release. + * + * The certification framework sets a minimum IAL per level and says a wholly + * anonymous responsible party can never hold a certified release. It leaves + * the calculation to "an external identity engine", but the evidence is + * already in the ecosystem: an `id_document` binding document records a + * verified eID, and a `social_connection` records one person attesting to + * another, signed by both. + * + * So the level is derived rather than asked, reduced to the weakest actor, and + * the reviewer may override it. + */ + +import { type BindingDocument, fetchBindingDocuments } from "./evault"; +import { type IdentityLevel, identityIndex } from "$lib/levels"; + +export interface ActorIdentity { + ename: string; + ial: IdentityLevel; + idDocuments: number; + attestations: number; + /** Attesters who were themselves passport-verified. */ + verifiedAttesters: number; + /** Set when the actor's documents could not be read at all. */ + error?: string; +} + +function normalize(ename: string): string { + const trimmed = ename.trim().toLowerCase(); + if (!trimmed) return ""; + return trimmed.startsWith("@") ? trimmed : `@${trimmed}`; +} + +/** The other party to a social connection, or null if it is malformed. */ +function counterparty(doc: BindingDocument, subject: string): string | null { + const parties = doc.data.parties; + if (Array.isArray(parties)) { + for (const party of parties) { + if (typeof party !== "string") continue; + if (normalize(party) !== subject) return normalize(party); + } + } + // Fall back to the signatures: a countersigned connection has two signers. + for (const signature of doc.signatures) { + if (typeof signature?.signer !== "string") continue; + if (normalize(signature.signer) !== subject) { + return normalize(signature.signer); + } + } + return null; +} + +/** A social connection only counts once both parties have signed it. */ +function isCountersigned(doc: BindingDocument): boolean { + const signers = new Set( + doc.signatures + .map((s) => (typeof s?.signer === "string" ? normalize(s.signer) : "")) + .filter(Boolean), + ); + return signers.size >= 2; +} + +function hasIdDocument(docs: BindingDocument[]): boolean { + return docs.some( + (d) => + d.type === "id_document" && + typeof d.data.vendor === "string" && + typeof d.data.reference === "string" && + d.signatures.length > 0, + ); +} + +/** + * Identity assurance for one eName. + * + * Counterparties are resolved one level deep only, and memoised: deciding + * whether an attester is themselves passport-verified needs their documents, + * but going further would walk the whole social graph, and a mutual attestation + * (A vouches for B, B vouches for A) would not terminate. + */ +export async function deriveIdentity( + ename: string, + cache = new Map(), + resolveAttesters = true, +): Promise { + const subject = normalize(ename); + const base: ActorIdentity = { + ename: subject, + ial: "IAL1", + idDocuments: 0, + attestations: 0, + verifiedAttesters: 0, + }; + + let docs: BindingDocument[]; + try { + docs = cache.get(subject) ?? (await fetchBindingDocuments(subject)); + cache.set(subject, docs); + } catch (error) { + // An unreadable vault is not evidence of identity, so it stays IAL1 — + // but say so, rather than letting it look like a considered result. + const reason = error instanceof Error ? error.message : String(error); + console.warn(`[ppa/identity] could not read ${subject}: ${reason}`); + return { + ...base, + error: reason, + }; + } + + const idDocuments = docs.filter((d) => d.type === "id_document").length; + const connections = docs.filter( + (d) => d.type === "social_connection" && isCountersigned(d), + ); + const passportVerified = hasIdDocument(docs); + + let verifiedAttesters = 0; + if (resolveAttesters) { + for (const doc of connections) { + const other = counterparty(doc, subject); + if (!other) continue; + // One level deep: do not resolve the attester's own attesters. + const attester = await deriveIdentity(other, cache, false); + if (identityIndex(attester.ial) >= identityIndex("IAL3")) { + verifiedAttesters++; + } + } + } + + let ial: IdentityLevel = "IAL1"; + if (passportVerified && verifiedAttesters >= 3) ial = "IAL4"; + else if (passportVerified) ial = "IAL3"; + else if (connections.length > 0) ial = "IAL2"; + + return { + ...base, + ial, + idDocuments, + attestations: connections.length, + verifiedAttesters, + }; +} + +/** The weakest actor decides what the release can be certified at. */ +export function minimumIdentity(actors: ActorIdentity[]): IdentityLevel { + if (actors.length === 0) return "IAL1"; + return actors.reduce( + (lowest, actor) => + identityIndex(actor.ial) < identityIndex(lowest) ? actor.ial : lowest, + "IAL4", + ); +} + +/** Every accountable person behind a release, de-duplicated, with their role. */ +export function accountableActors(submission: { + authorEnames: string[]; + submissionProof: { statement: { signerEName: string } }; +}): { ename: string; role: string }[] { + const seen = new Map(); + const add = (ename: string, role: string) => { + const key = normalize(ename); + if (!key || seen.has(key)) return; + seen.set(key, role); + }; + add(submission.submissionProof.statement.signerEName, "releaseSigner"); + for (const author of submission.authorEnames) add(author, "author"); + return Array.from(seen, ([ename, role]) => ({ ename, role })); +} diff --git a/services/ppa/src/lib/server/jwt.ts b/services/ppa/src/lib/server/jwt.ts index d96eace9f..c8acaf117 100644 --- a/services/ppa/src/lib/server/jwt.ts +++ b/services/ppa/src/lib/server/jwt.ts @@ -70,6 +70,9 @@ export interface AccreditationClaims { submissionEnvelopeId: string; supersedes: string | null; applicantResponse: string | null; + frameworkVersion: string; + computedLevel: string | null; + minimumIal: string; } /** Where a verifier fetches the key set that validates our statements. */ @@ -102,6 +105,9 @@ export async function signAccreditation( submissionEnvelopeId: claims.submissionEnvelopeId, supersedes: claims.supersedes, applicantResponse: claims.applicantResponse, + frameworkVersion: claims.frameworkVersion, + computedLevel: claims.computedLevel, + minimumIal: claims.minimumIal, }) .setProtectedHeader({ alg: ALG, kid: KID, typ: "JWT" }) .setIssuer(publicUrl()) diff --git a/services/ppa/src/lib/server/ontology.ts b/services/ppa/src/lib/server/ontology.ts index 83859ef09..da27181bf 100644 --- a/services/ppa/src/lib/server/ontology.ts +++ b/services/ppa/src/lib/server/ontology.ts @@ -76,6 +76,45 @@ export interface Submission { raw: Record; } +/** PlatformAssessment — the findings behind one decision. */ +export const PLATFORM_ASSESSMENT_ONTOLOGY = + "b0c8cfad-2872-4fb7-9d99-278f257bb922"; + +export interface AssessmentDimension { + id: string; + answer: string; + level: number; + source: "derived" | "reviewer"; + note?: string | null; +} + +export interface AssessmentActor { + ename: string; + role: string; + ial: "IAL1" | "IAL2" | "IAL3" | "IAL4"; + idDocuments: number; + attestations: number; + verifiedAttesters: number; + overridden: boolean; + note?: string | null; +} + +export interface Assessment { + assessmentId: string; + platformEName: string; + platformVersion: string; + frameworkVersion: string; + dimensions: AssessmentDimension[]; + actors: AssessmentActor[]; + minimumIal: "IAL1" | "IAL2" | "IAL3" | "IAL4"; + computedLevel: string | null; + limitingDimension: string | null; + awardedLevel: string | null; + overrideReason: string | null; + reviewedByEName: string; + createdAt: string; +} + /** A decision the PPA has issued, as read back out of its own eVault. */ export interface Accreditation { accreditationId: string; @@ -94,6 +133,10 @@ export interface Accreditation { submissionEnvelopeId: string; /** The decision this one replaces for the same version, if any. */ supersedes: string | null; + frameworkVersion: string; + computedLevel: string | null; + minimumIal: string; + assessmentEnvelopeId: string; jws: string; createdAt: string; } diff --git a/services/ppa/src/lib/server/reputation.ts b/services/ppa/src/lib/server/reputation.ts new file mode 100644 index 000000000..7bd871fb6 --- /dev/null +++ b/services/ppa/src/lib/server/reputation.ts @@ -0,0 +1,96 @@ +/** + * eReputation evidence for the assessment. + * + * References are signed statements about a person or a platform, published by + * the eReputation application and readable without credentials, so the + * framework's reputation rows can be counted rather than asserted. + */ + +import { ereputationUrl } from "./env"; + +interface Reference { + id: string; + targetType: string; + targetId: string; + referenceType?: string; + authorId?: string; + status?: string; + signature?: string; +} + +export interface ReputationEvidence { + /** Signed references held by the platform itself. */ + platformReferences: number; + /** Signed references per accountable actor, keyed by eName. */ + actorReferences: Record; + /** Weakest actor's count — the framework asks per responsible actor. */ + minimumActorReferences: number; + /** Platform references written by someone who is not an accountable actor. */ + independentReviews: number; + /** Set when the service could not be reached, so counts are not evidence. */ + error?: string; +} + +/** Only a signed, unrevoked reference counts. */ +function isSigned(reference: Reference): boolean { + return reference.status === "signed"; +} + +async function referencesFor( + targetType: string, + targetId: string, +): Promise { + const url = new URL( + `/api/references/target/${targetType}/${encodeURIComponent(targetId)}`, + ereputationUrl(), + ).toString(); + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`eReputation returned ${res.status}`); + const body = (await res.json()) as { references?: Reference[] }; + return (body.references ?? []).filter(isSigned); +} + +export async function collectReputation( + platformName: string, + actors: { ename: string }[], +): Promise { + const empty: ReputationEvidence = { + platformReferences: 0, + actorReferences: {}, + minimumActorReferences: 0, + independentReviews: 0, + }; + + try { + const [platform, ...perActor] = await Promise.all([ + referencesFor("platform", platformName), + ...actors.map((a) => referencesFor("user", a.ename)), + ]); + + const actorReferences: Record = {}; + actors.forEach((actor, index) => { + actorReferences[actor.ename] = perActor[index]?.length ?? 0; + }); + + // "Independent" means written by someone with no accountability for the + // release — a reference from an author about their own platform is not + // an outside opinion. + const insiders = new Set(actors.map((a) => a.ename.toLowerCase())); + const independentReviews = platform.filter( + (r) => !insiders.has(String(r.authorId ?? "").toLowerCase()), + ).length; + + return { + platformReferences: platform.length, + actorReferences, + minimumActorReferences: actors.length + ? Math.min(...actors.map((a) => actorReferences[a.ename] ?? 0)) + : 0, + independentReviews, + }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + console.warn(`[ppa/reputation] eReputation unavailable: ${reason}`); + return { ...empty, error: reason }; + } +} diff --git a/services/ppa/src/routes/submissions/[ename]/+page.server.ts b/services/ppa/src/routes/submissions/[ename]/+page.server.ts index 9a75caa38..651116a76 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.server.ts +++ b/services/ppa/src/routes/submissions/[ename]/+page.server.ts @@ -9,10 +9,22 @@ import { getAuthors, listSubmissions, } from "$lib/server/aaas"; -import { storeAccreditation } from "$lib/server/evault"; +import { storeAccreditation, storeAssessment } from "$lib/server/evault"; import { jwksUri, signAccreditation } from "$lib/server/jwt"; -import { type Accreditation, isAccessLevel } from "$lib/server/ontology"; +import { + type Accreditation, + type Assessment, +} from "$lib/server/ontology"; +import { computeLevel, isAccessLevel, type DimensionAnswer } from "$lib/levels"; import { listDomains, validDomains } from "$lib/server/domains"; +import { collectReputation } from "$lib/server/reputation"; +import { deriveAnswers, loadFramework } from "$lib/server/framework"; +import { + accountableActors, + deriveIdentity, + minimumIdentity, + type ActorIdentity, +} from "$lib/server/identity"; import { repositoryBaseUrl } from "$lib/server/env"; import { submissionSupersedesDecision } from "$lib/server/submission-proof"; @@ -41,6 +53,28 @@ export const load: PageServerLoad = async ({ params }) => { .filter((d) => d.platformEName === ename) .sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1)); + const framework = await loadFramework(); + + // Identity is derived per actor and reduced to the weakest, because that is + // what the framework caps the level on. + const cache = new Map(); + const roles = accountableActors(submission); + const identities = await Promise.all( + roles.map(async (actor) => ({ + ...(await deriveIdentity(actor.ename, cache)), + role: actor.role, + })), + ); + const minimumIal = minimumIdentity(identities as ActorIdentity[]); + + const reputation = await collectReputation(submission.platformName, identities); + const derivedAnswers = deriveAnswers(framework, { + submission, + minimumIal, + actors: identities as ActorIdentity[], + reputation, + }); + // Built here so the page never has to know about configuration. const base = repositoryBaseUrl(); const repository = submission.submissionProof.statement.repository; @@ -61,6 +95,11 @@ export const load: PageServerLoad = async ({ params }) => { return { submission, history, + framework, + actors: identities, + minimumIal, + derivedAnswers, + reputation, repositoryUrl, authors: await getAuthors(submission.authorEnames, messenger), messengerConfigured: messenger !== null, @@ -140,8 +179,64 @@ export const actions: Actions = { }); } + // The matrix arrives as one field per dimension; the derived rows are + // recomputed here rather than trusted from the form, so a crafted post + // cannot claim evidence the app did not establish. + const framework = await loadFramework(); + const reviewerAnswers: DimensionAnswer[] = []; + for (const dimension of framework.dimensions) { + if (dimension.source !== "reviewer") continue; + const raw = form.get(`dimension:${dimension.id}`); + if (raw === null) continue; + const option = Number.parseInt(String(raw), 10); + if (Number.isNaN(option) || !dimension.options[option]) continue; + reviewerAnswers.push({ id: dimension.id, option }); + } + const level = decision === "granted" ? (rawLevel as string) : null; const accreditationId = randomUUID(); + const assessmentId = randomUUID(); + + const cache = new Map(); + const roles = accountableActors(submission); + const identities = await Promise.all( + roles.map(async (actor) => ({ + ...(await deriveIdentity(actor.ename, cache)), + role: actor.role, + })), + ); + const minimumIal = minimumIdentity(identities); + const reputation = await collectReputation(submission.platformName, identities); + const derived = deriveAnswers(framework, { + submission, + minimumIal, + actors: identities, + reputation, + }); + const allAnswers = [ + ...derived.map((d) => ({ id: d.id, option: d.option })), + ...reviewerAnswers, + ]; + const computed = computeLevel(framework, allAnswers, minimumIal); + + // An award that differs from the evidence has to say why, so a + // divergence between judgement and matrix is never silent. + const overrideReason = String(form.get("overrideReason") ?? "").trim(); + if ( + decision === "granted" && + level !== computed.level && + !overrideReason + ) { + return fail(400, { + message: + computed.level === null + ? `The assessment supports no level yet. Explain why you are awarding ${level} anyway.` + : `The assessment supports ${computed.level}. Explain why you are awarding ${level} instead.`, + decision, + statement, + level: rawLevel, + }); + } // A version can be refused and reapply, so name the decision this one // replaces instead of leaving the order to be inferred. const applicantResponse = @@ -167,6 +262,9 @@ export const actions: Actions = { submissionEnvelopeId: submission.submissionEnvelopeId, supersedes: previous?.accreditationId ?? null, applicantResponse, + frameworkVersion: framework.frameworkVersion, + computedLevel: computed.level, + minimumIal, }); const accreditation: Accreditation = { @@ -184,12 +282,59 @@ export const actions: Actions = { supersedes: previous?.accreditationId ?? null, applicantResponse, applicantSubmittedAt, + frameworkVersion: framework.frameworkVersion, + computedLevel: computed.level, + minimumIal, + assessmentEnvelopeId: "", jws, createdAt: new Date().toISOString(), }; - await storeAccreditation(accreditation); - return { issued: accreditation }; + const assessment: Assessment = { + assessmentId, + platformEName: ename, + platformVersion: submission.version, + frameworkVersion: framework.frameworkVersion, + dimensions: allAnswers.map((answer) => { + const dimension = framework.dimensions.find( + (d) => d.id === answer.id, + ); + const option = dimension?.options[answer.option]; + return { + id: answer.id, + answer: option?.label ?? "", + level: option?.level ?? -1, + source: dimension?.source ?? "reviewer", + note: null, + }; + }), + actors: identities.map((actor) => ({ + ename: actor.ename, + role: actor.role, + ial: actor.ial, + idDocuments: actor.idDocuments, + attestations: actor.attestations, + verifiedAttesters: actor.verifiedAttesters, + overridden: false, + note: actor.error ?? null, + })), + minimumIal, + computedLevel: computed.level, + limitingDimension: computed.limiting, + awardedLevel: level as Assessment["awardedLevel"], + overrideReason: overrideReason || null, + reviewedByEName: reviewer, + createdAt: new Date().toISOString(), + }; + // Findings first: the certificate cites the assessment, so the + // evidence must exist before anything points at it. + const assessmentEnvelopeId = await storeAssessment(assessment); + + await storeAccreditation({ + ...accreditation, + assessmentEnvelopeId, + }); + return { issued: accreditation, assessment }; } catch (err) { console.error("[ppa] failed issuing accreditation:", err); return fail(500, { diff --git a/services/ppa/src/routes/submissions/[ename]/+page.svelte b/services/ppa/src/routes/submissions/[ename]/+page.svelte index 963a9f184..4c588cb45 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.svelte +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -1,18 +1,64 @@ +{#if assessmentOpen} + +
+ + + +
+
+
+

Assessment

+

+ {data.submission.displayName} v{data.submission.version} +

+
+ +
+ +
+ + + + + +
+

Issue a decision

+

+ Signed and published, so anyone can confirm it came from + the association. +

+ + {#if form?.message} + + {/if} + +
{ + submitting = true; + return async ({ update }) => { + await update(); + submitting = false; + }; + }} + > + +
+ {#each [["granted", "Grant"], ["denied", "Deny"]] as [value, label] (value)} + + {/each} +
+ + {#if decision === "granted"} +
+

Level to award

+

+ {level} + {#if isOverride} + + overriding {computed.level ?? "no level"} + + {:else} + as calculated + {/if} +

+ +
+ + +
+ + Award a different level + + +
+ {#each ACCESS_LEVELS as option (option)} + + {/each} +
+ + {#if isOverride} + + {/if} +
+ {/if} + + {#if decision === "granted"} +
+ Areas requested +

+ {data.submission.requestedDomains.length > 0 + ? "Derived from the record types this platform declares it uses. Deselect anything you are not approving." + : "This platform has not declared what it works with, so there is nothing to approve."} +

+
+ {#each data.requestedDomains as domain (domain.id)} + + {/each} +
+
+ {/if} + + {#if isOverride} +
+ + +
+ {/if} + +
+ + +
+ + {#each answers as answer (answer.id)} + + {/each} + + +
+
+
+
+
+{/if} + {:else}
-

Issue a decision

+

Decision

- Your decision is signed and published, so anyone can confirm - it came from the association. + Work through the assessment, then award a level. Everything + you record is signed and published.

- {#if form?.message} - - {/if} - -
{ - submitting = true; - return async ({ update }) => { - await update(); - submitting = false; - }; - }} + -
- - -
- - -
{/if}