From 86bb79a6b010bab87bd3ecffb56384d29a1a0784 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:16:52 +0800 Subject: [PATCH 01/29] feat: publish certification level, identity assurance and assessment schemas --- .../ontology/schemas/certification-level.json | 59 +++++ .../schemas/identity-assurance-level.json | 49 ++++ .../schemas/platformAccreditation.json | 38 +++- .../ontology/schemas/platformAssessment.json | 210 ++++++++++++++++++ 4 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 services/ontology/schemas/certification-level.json create mode 100644 services/ontology/schemas/identity-assurance-level.json create mode 100644 services/ontology/schemas/platformAssessment.json 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..285083dce --- /dev/null +++ b/services/ontology/schemas/platformAssessment.json @@ -0,0 +1,210 @@ +{ + "$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 derived this from verified evidence or a reviewer asserted it." + }, + "unverified": { + "type": "boolean", + "description": "True when the answer relies on evidence the ecosystem cannot yet verify, such as eReputation signals." + }, + "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 +} From cdddb7c43194afa2c7635541b927d886ab24cdab Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:17:16 +0800 Subject: [PATCH 02/29] feat: add the certification matrix and level computation --- pnpm-lock.yaml | 118 +++++++++- .../ppa/config/certification-framework.json | 201 ++++++++++++++++++ services/ppa/package.json | 6 +- services/ppa/src/lib/levels.spec.ts | 75 +++++++ services/ppa/src/lib/levels.ts | 131 +++++++++++- 5 files changed, 524 insertions(+), 7 deletions(-) create mode 100644 services/ppa/config/certification-framework.json create mode 100644 services/ppa/src/lib/levels.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c38cb2516..28c9e5419 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4135,6 +4135,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: @@ -30162,6 +30165,26 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/browser@3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(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@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@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/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@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))(vitest@3.2.4)': dependencies: '@testing-library/dom': 10.4.1 @@ -30181,6 +30204,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@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))(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@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)) + '@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@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) + 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 +30309,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 @@ -42234,6 +42285,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 @@ -42525,6 +42597,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@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@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 @@ -42553,7 +42669,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.12 '@types/node': 22.19.15 - '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(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))(vitest@3.2.4) + '@vitest/browser': 3.2.4(bufferutil@4.1.0)(playwright@1.58.2)(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))(vitest@3.2.4) jsdom: 19.0.0(bufferutil@4.1.0) transitivePeerDependencies: - jiti diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json new file mode 100644 index 000000000..736825f0a --- /dev/null +++ b/services/ppa/config/certification-framework.json @@ -0,0 +1,201 @@ +{ + "$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 from evidence it has already verified.", + "frameworkVersion": "2", + "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": "Not demonstrated" }, + { "level": 5, "label": "Required — demonstrated" } + ] + }, + { + "id": "functional-review", + "label": "Functional review", + "source": "reviewer", + "options": [ + { "level": 0, "label": "None" }, + { "level": 1, "label": "Basic" }, + { "level": 3, "label": "Reviewed" }, + { "level": 4, "label": "Extended" }, + { "level": 5, "label": "High assurance" } + ] + }, + { + "id": "identity-assurance", + "label": "Responsible actors — minimum identity assurance", + "source": "derived", + "options": [ + { "level": -1, "label": "IAL1 — anonymous" }, + { "level": 0, "label": "IAL2 — attested" }, + { "level": 2, "label": "IAL3 — passport-verified" }, + { "level": 5, "label": "IAL4 — passport and independent attestation" } + ] + }, + { + "id": "roles", + "label": "Developer / deployer roles", + "source": "reviewer", + "options": [ + { "level": 0, "label": "Responsible person recorded" }, + { "level": 1, "label": "Normally the same person" }, + { "level": 2, "label": "May differ; roles recorded" }, + { "level": 4, "label": "All key actors known" }, + { "level": 5, "label": "Fully accountable chain" } + ] + }, + { + "id": "development-method", + "label": "AI / development method", + "source": "reviewer", + "options": [ + { "level": 0, "label": "Optional — not declared" }, + { "level": 1, "label": "Declared" }, + { "level": 2, "label": "Recorded" }, + { "level": 4, "label": "Traceable" }, + { "level": 5, "label": "Auditable" } + ] + }, + { + "id": "source-available", + "label": "Source code available", + "source": "derived", + "options": [ + { "level": -1, "label": "No" }, + { "level": 5, "label": "Yes" } + ] + }, + { + "id": "code-review", + "label": "PPA code review", + "source": "reviewer", + "options": [ + { "level": 0, "label": "None" }, + { "level": 1, "label": "Automated gross-error scan" }, + { "level": 3, "label": "Manual review each release" }, + { "level": 4, "label": "Extended" }, + { "level": 5, "label": "Extensive" } + ] + }, + { + "id": "interview", + "label": "Interview", + "source": "reviewer", + "options": [ + { "level": 1, "label": "None" }, + { "level": 2, "label": "Owner, lead developer and deployer/operator" }, + { "level": 3, "label": "All key responsible actors" }, + { "level": 4, "label": "Key actors and relevant technical leads" }, + { "level": 5, "label": "All accountable leads including security and key custody" } + ] + }, + { + "id": "provenance", + "label": "Development provenance / commits", + "source": "derived", + "options": [ + { "level": -1, "label": "Not recorded" }, + { "level": 1, "label": "Recorded from the start" }, + { "level": 2, "label": "Reviewed" }, + { "level": 3, "label": "Required and reviewed" }, + { "level": 4, "label": "Extended review" }, + { "level": 5, "label": "Fully auditable" } + ] + }, + { + "id": "actor-reputation", + "label": "Responsible actors — eReputation / professional references", + "source": "reviewer", + "unverified": true, + "options": [ + { "level": 1, "label": "Recorded; no minimum" }, + { "level": 2, "label": "Emerging; no minimum" }, + { "level": 3, "label": "3 signed professional references" }, + { "level": 4, "label": "5 references, including 2 recognised professionals" }, + { "level": 5, "label": "10 high-reputation professional or organisational references" } + ] + }, + { + "id": "track-record", + "label": "Application track record / authenticated eReputation", + "source": "reviewer", + "unverified": true, + "options": [ + { "level": 2, "label": "No minimum" }, + { "level": 3, "label": "1 prior Level 2+ app with positive eReputation; 50 authenticated user signals" }, + { "level": 4, "label": "Established prior-app reputation and ~1,000 authenticated signals" }, + { "level": 5, "label": "Extensive strong portfolio and ~10,000 authenticated signals" } + ] + }, + { + "id": "independent-review", + "label": "Independent professional review", + "source": "reviewer", + "unverified": true, + "options": [ + { "level": 3, "label": "None" }, + { "level": 4, "label": "1 independent signed review" }, + { "level": 5, "label": "Multiple independent reviews" } + ] + }, + { + "id": "external-certification", + "label": "External certification", + "source": "reviewer", + "unverified": true, + "options": [ + { "level": 0, "label": "Not relevant" }, + { "level": 1, "label": "May be recorded" }, + { "level": 2, "label": "Considered" }, + { "level": 3, "label": "Recognised" }, + { "level": 5, "label": "Important — present and weighted" } + ] + }, + { + "id": "deployment-assurance", + "label": "Deployment assurance", + "source": "derived", + "options": [ + { "level": 0, "label": "None" }, + { "level": 1, "label": "Basic declaration" }, + { "level": 2, "label": "Reasonable check" }, + { "level": 3, "label": "Release provenance verified" }, + { "level": 4, "label": "Strong assurance" }, + { "level": 5, "label": "High assurance" } + ] + }, + { + "id": "key-assurance", + "label": "Key / infrastructure assurance", + "source": "reviewer", + "options": [ + { "level": 0, "label": "None" }, + { "level": 1, "label": "Minimal" }, + { "level": 2, "label": "Basic" }, + { "level": 3, "label": "Reviewed where relevant" }, + { "level": 4, "label": "Strong" }, + { "level": 5, "label": "Hardware-backed where appropriate" } + ] + }, + { + "id": "findings-recorded", + "label": "Findings and signed certificate in eVault", + "source": "derived", + "options": [ + { "level": 0, "label": "Minimal statement" }, + { "level": 5, "label": "Yes — full findings recorded" } + ] + } + ] +} 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/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts new file mode 100644 index 000000000..195dead80 --- /dev/null +++ b/services/ppa/src/lib/levels.spec.ts @@ -0,0 +1,75 @@ +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("is pinned by the weakest dimension, not the average", () => { + const answers = [ + ...allAt(5).filter((a) => a.id !== "code-review"), + pick("code-review", 1), + ]; + const result = computeLevel(framework, answers, "IAL4"); + expect(result.level).toBe("L1"); + expect(result.limiting).toBe("code-review"); + }); + + 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("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.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..4c9753bdf 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -1,15 +1,138 @@ /** - * 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"; + /** The ecosystem cannot verify this evidence yet (no eReputation system). */ + unverified?: boolean; + 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; + /** Dimension id that held the level down, or "identity" for the IAL floor. */ + limiting: string | null; + /** Per-dimension satisfied level, for display. */ + perDimension: { id: string; level: number }[]; +} + +/** + * The level a set of answers supports. + * + * The framework is explicit that "a strong result in one dimension does not + * erase a weakness in another", so this is the minimum across dimensions — + * never an average and never the best row. The identity floor caps it + * separately: L0 needs IAL2, L1–L2 need IAL3, L3–L5 need IAL4, so the weakest + * accountable actor can pull the whole release down. + * + * Returns null when nothing is supportable — an unanswered dimension, an + * answer that fails even L0, 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 = ACCESS_LEVELS.length - 1; + let limiting: string | null = null; + + 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; + } + } + + // 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 < lowest) { + lowest = identityCap; + limiting = "identity"; + } + + return { level: levelFromIndex(lowest), limiting, perDimension }; +} From 77300cee90b765f15ddb94c80b63a20df347e155 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:17:16 +0800 Subject: [PATCH 03/29] feat: derive identity assurance from binding documents --- services/ppa/src/lib/server/evault.ts | 95 +++++++++++ services/ppa/src/lib/server/identity.spec.ts | 164 ++++++++++++++++++ services/ppa/src/lib/server/identity.ts | 168 +++++++++++++++++++ 3 files changed, 427 insertions(+) create mode 100644 services/ppa/src/lib/server/identity.spec.ts create mode 100644 services/ppa/src/lib/server/identity.ts diff --git a/services/ppa/src/lib/server/evault.ts b/services/ppa/src/lib/server/evault.ts index bde76ae30..8ce342293 100644 --- a/services/ppa/src/lib/server/evault.ts +++ b/services/ppa/src/lib/server/evault.ts @@ -18,6 +18,23 @@ import { PLATFORM_ACCREDITATION_ONTOLOGY, } from "./ontology"; +const BINDING_DOCUMENTS_QUERY = gql` + query GetBindingDocuments($first: Int!, $after: String) { + bindingDocuments(first: $first, after: $after) { + edges { + node { + id + parsed + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +`; + const CREATE_META_ENVELOPE = gql` mutation CreateMetaEnvelope($input: MetaEnvelopeInput!) { createMetaEnvelope(input: $input) { @@ -32,6 +49,23 @@ const CREATE_META_ENVELOPE = gql` } `; +export interface BindingDocument { + id: string; + subject: string; + type: string; + data: Record; + 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 +117,67 @@ 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; +} + /** * 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/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 })); +} From ca1bfb800273047144af844ebc106bd83a0e0668 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:29:38 +0800 Subject: [PATCH 04/29] feat: guide certification with the assessment matrix and derived identity --- services/ppa/src/lib/AssessmentMatrix.svelte | 184 ++++++++++++++++++ services/ppa/src/lib/IdentityPanel.svelte | 95 +++++++++ services/ppa/src/lib/server/aaas.ts | 51 +++++ services/ppa/src/lib/server/evault.ts | 51 +++++ services/ppa/src/lib/server/framework.ts | 123 ++++++++++++ services/ppa/src/lib/server/jwt.ts | 6 + services/ppa/src/lib/server/ontology.ts | 44 +++++ .../submissions/[ename]/+page.server.ts | 158 ++++++++++++++- .../routes/submissions/[ename]/+page.svelte | 84 +++++++- 9 files changed, 790 insertions(+), 6 deletions(-) create mode 100644 services/ppa/src/lib/AssessmentMatrix.svelte create mode 100644 services/ppa/src/lib/IdentityPanel.svelte create mode 100644 services/ppa/src/lib/server/framework.ts diff --git a/services/ppa/src/lib/AssessmentMatrix.svelte b/services/ppa/src/lib/AssessmentMatrix.svelte new file mode 100644 index 000000000..066e30c43 --- /dev/null +++ b/services/ppa/src/lib/AssessmentMatrix.svelte @@ -0,0 +1,184 @@ + + +
+

Assessment

+

+ The level is the weakest of these dimensions — a strong result in one + does not make up for a weakness in another. 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 limitingLabel} +

+ Held at {result.level ?? "no level"} by + {limitingLabel}. +

+

+ Raising that one row is what raises the level. +

+ {/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} + + + {#if dimension.unverified} +

+ Nothing in the ecosystem can verify this yet — recorded as + your assertion. +

+ {/if} + +
+ {#each dimension.options as option, index (option.label)} + + {/each} +
+
+ {/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/server/aaas.ts b/services/ppa/src/lib/server/aaas.ts index 5b10bc20e..3cff54133 100644 --- a/services/ppa/src/lib/server/aaas.ts +++ b/services/ppa/src/lib/server/aaas.ts @@ -610,3 +610,54 @@ export async function currentAccreditations(): Promise { + if (!awarenessApiKey()) return []; + + let packets: Packet[]; + try { + packets = await all({ ontology: DEPLOYMENT_PROFILE_ONTOLOGY }); + } catch (error) { + console.error("[ppa/aaas] failed loading deployments:", error); + return []; + } + + const byDeployment = new Map(); + for (const packet of packets) { + const data = packet.data; + if (!data) continue; + if (str(data.platformEname) !== platformEName) continue; + if (str(data.version) !== version) continue; + const ename = str(data.deploymentEname); + if (!ename) continue; + byDeployment.set(ename, { + deploymentEName: ename, + platformEName: str(data.platformEname), + version: str(data.version), + releaseTag: str(data.releaseTag), + commitSha: str(data.commitSha), + environment: str(data.environment), + }); + } + return Array.from(byDeployment.values()); +} diff --git a/services/ppa/src/lib/server/evault.ts b/services/ppa/src/lib/server/evault.ts index 8ce342293..a0ebb5d7d 100644 --- a/services/ppa/src/lib/server/evault.ts +++ b/services/ppa/src/lib/server/evault.ts @@ -15,7 +15,9 @@ import { GraphQLClient, gql } from "graphql-request"; import { registryUrl } from "./env"; import { type Accreditation, + type Assessment, PLATFORM_ACCREDITATION_ONTOLOGY, + PLATFORM_ASSESSMENT_ONTOLOGY, } from "./ontology"; const BINDING_DOCUMENTS_QUERY = gql` @@ -178,6 +180,55 @@ export async function fetchBindingDocuments( 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..7e80a1780 --- /dev/null +++ b/services/ppa/src/lib/server/framework.ts @@ -0,0 +1,123 @@ +/** + * 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 } from "node:fs/promises"; +import path from "node:path"; +import type { ActorIdentity } from "./identity"; +import type { Submission } from "./ontology"; +import type { DeploymentRecord } from "./aaas"; +import type { Framework, IdentityLevel } from "$lib/levels"; + +const CACHE = Symbol.for("ppa.framework"); +const store = globalThis as typeof globalThis & { [CACHE]?: Framework }; + +export async function loadFramework(): Promise { + if (store[CACHE]) return store[CACHE]; + // cwd is services/ppa under both `vite dev` and `node build/index.js`. + const file = path.resolve(process.cwd(), "config/certification-framework.json"); + const framework = JSON.parse(await readFile(file, "utf8")) as Framework; + store[CACHE] = framework; + return framework; +} + +/** 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[]; + deployments: DeploymentRecord[]; + }, +): DerivedAnswer[] { + const { submission, minimumIal, actors, deployments } = 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.", + }); + + answers.push({ + id: "deployment-assurance", + option: at("deployment-assurance", deployments.length > 0 ? 3 : 0), + evidence: + deployments.length > 0 + ? `${deployments.length} deployment${deployments.length === 1 ? "" : "s"} attested against this exact release.` + : "No deployment has been attested against this release.", + }); + + 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/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..ad38f8e12 100644 --- a/services/ppa/src/lib/server/ontology.ts +++ b/services/ppa/src/lib/server/ontology.ts @@ -76,6 +76,46 @@ 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"; + unverified?: boolean; + 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 +134,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/routes/submissions/[ename]/+page.server.ts b/services/ppa/src/routes/submissions/[ename]/+page.server.ts index 9a75caa38..cc99e4013 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.server.ts +++ b/services/ppa/src/routes/submissions/[ename]/+page.server.ts @@ -7,12 +7,24 @@ import { listAccreditations, findMessenger, getAuthors, + listDeployments, 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 { 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,30 @@ 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 deployments = await listDeployments(ename, submission.version).catch( + () => [], + ); + const derivedAnswers = deriveAnswers(framework, { + submission, + minimumIal, + actors: identities as ActorIdentity[], + deployments, + }); + // Built here so the page never has to know about configuration. const base = repositoryBaseUrl(); const repository = submission.submissionProof.statement.repository; @@ -61,6 +97,11 @@ export const load: PageServerLoad = async ({ params }) => { return { submission, history, + framework, + actors: identities, + minimumIal, + derivedAnswers, + deployments, repositoryUrl, authors: await getAuthors(submission.authorEnames, messenger), messengerConfigured: messenger !== null, @@ -140,8 +181,66 @@ 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 deployments = await listDeployments(ename, submission.version).catch( + () => [], + ); + const derived = deriveAnswers(framework, { + submission, + minimumIal, + actors: identities, + deployments, + }); + 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 +266,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 +286,60 @@ 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", + unverified: dimension?.unverified ?? false, + 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..ff92a71f5 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.svelte +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -3,16 +3,39 @@ import { ACCESS_LEVELS } from "$lib/levels"; import DomainChips from "$lib/DomainChips.svelte"; import ReviewThread from "$lib/ReviewThread.svelte"; + import AssessmentMatrix from "$lib/AssessmentMatrix.svelte"; + import IdentityPanel from "$lib/IdentityPanel.svelte"; + import { + computeLevel, + type AccessLevel, + type DimensionAnswer, + } from "$lib/levels"; import PlatformMark from "$lib/PlatformMark.svelte"; import StatusPill from "$lib/StatusPill.svelte"; let { data, form } = $props(); let decision = $state<"granted" | "denied">("granted"); - let level = $state("L1"); + let level = $state("L1"); // Everything the platform asked for starts approved; the reviewer narrows. // Re-seeded per submission so moving between platforms does not carry a // previous selection across. + let answers = $state([]); + let overrideReason = $state(""); + + let computed = $derived( + computeLevel(data.framework, answers, data.minimumIal), + ); + // Following the evidence is the default; the reviewer changes it only + // deliberately, and then has to say why. + let followComputed = $state(true); + $effect(() => { + if (followComputed && computed.level) level = computed.level; + }); + let isOverride = $derived( + decision === "granted" && level !== computed.level, + ); + let chosen = $state([]); let seededFor = $state(null); $effect(() => { @@ -164,6 +187,21 @@ + + + + +
+

+ Assessment supports +

+

+ {computed.level ?? "no level yet"} +

+
+ {#if form?.message}

- Access level + + Access level + {#if isOverride} + — overriding the assessment + {/if} +

{#each ACCESS_LEVELS as option (option)}
+{/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.

-
-

- Assessment supports -

-

- {computed.level ?? "no level yet"} -

-
- - {#if form?.message} - - {/if} - -
{ - submitting = true; - return async ({ update }) => { - await update(); - submitting = false; - }; - }} + -
- - -
- - {#each answers as answer (answer.id)} - - {/each} - - -
{/if} From 0909cc3df232585663f644edfdfc3b65497998c7 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:48:21 +0800 Subject: [PATCH 06/29] feat: score the assessment by geometric mean and hide the level override --- services/ppa/src/lib/AssessmentResult.svelte | 106 ++++++++++++++++++ services/ppa/src/lib/levels.spec.ts | 30 ++++- services/ppa/src/lib/levels.ts | 63 ++++++++--- .../routes/submissions/[ename]/+page.svelte | 58 ++++++++-- 4 files changed, 233 insertions(+), 24 deletions(-) create mode 100644 services/ppa/src/lib/AssessmentResult.svelte diff --git a/services/ppa/src/lib/AssessmentResult.svelte b/services/ppa/src/lib/AssessmentResult.svelte new file mode 100644 index 000000000..0782fc605 --- /dev/null +++ b/services/ppa/src/lib/AssessmentResult.svelte @@ -0,0 +1,106 @@ + + +
+

Calculation

+

+ The level is the geometric mean of every dimension, so a weak row pulls + the result down sharply while a single middling one does not pin it. +

+ +
+
+

Geometric mean

+

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

+
+
+
+

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} +

+ Capped at {result.level} by the identity floor — the weakest + accountable actor is {minimumIal}. +

+ {: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/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts index 195dead80..d3acb1ef2 100644 --- a/services/ppa/src/lib/levels.spec.ts +++ b/services/ppa/src/lib/levels.spec.ts @@ -39,16 +39,41 @@ describe("computeLevel", () => { expect(computeLevel(framework, allAt(0), "IAL2").level).toBe("L0"); }); - it("is pinned by the weakest dimension, not the average", () => { + 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"); - expect(result.level).toBe("L1"); + // 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("holds the release at L0 when any dimension meets nothing above it", () => { + const answers = [ + ...allAt(5).filter((a) => a.id !== "functional-review"), + pick("functional-review", 0), + ]; + const result = computeLevel(framework, answers, "IAL4"); + expect(result.score).toBe(0); + expect(result.level).toBe("L0"); + }); + 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"); @@ -64,6 +89,7 @@ describe("computeLevel", () => { 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"); }); diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts index ecdd961a6..d19f1355b 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -76,23 +76,32 @@ export interface DimensionAnswer { export interface ComputedLevel { level: AccessLevel | null; - /** Dimension id that held the level down, or "identity" for the IAL floor. */ + /** The geometric mean itself, before flooring — shown in the calculation. */ + score: number; + /** 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 level a set of answers supports: the geometric mean of the per-dimension + * levels, floored. * - * The framework is explicit that "a strong result in one dimension does not - * erase a weakness in another", so this is the minimum across dimensions — - * never an average and never the best row. The identity floor caps it - * separately: L0 needs IAL2, L1–L2 need IAL3, L3–L5 need IAL4, so the weakest - * accountable actor can pull the whole release down. + * 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, and a row at zero takes the whole + * product to zero — without letting one middling row pin an otherwise strong + * release to its own value the way a strict minimum did. * - * Returns null when nothing is supportable — an unanswered dimension, an - * answer that fails even L0, or an anonymous responsible party. + * 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, @@ -102,8 +111,9 @@ export function computeLevel( const byId = new Map(answers.map((a) => [a.id, a])); const perDimension: { id: string; level: number }[] = []; - let lowest = ACCESS_LEVELS.length - 1; + 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); @@ -116,8 +126,29 @@ export function computeLevel( lowest = satisfied; limiting = dimension.id; } + if (satisfied < 0) blocked = true; + } + + if (blocked || perDimension.length === 0) { + return { level: null, score: 0, limiting, blocked: true, perDimension }; } + // Geometric mean. A single zero takes the product to zero, which is the + // intended behaviour: a dimension that meets nothing above L0 holds the + // whole release at L0. + const product = perDimension.reduce((acc, d) => acc * d.level, 1); + const score = + product === 0 + ? 0 + : Math.exp( + perDimension.reduce((acc, d) => acc + Math.log(d.level), 0) / + perDimension.length, + ); + + // exp(mean(ln 5)) 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); + // The identity floor: the highest level whose required IAL is met. let identityCap = -1; for (let i = ACCESS_LEVELS.length - 1; i >= 0; i--) { @@ -127,10 +158,16 @@ export function computeLevel( break; } } - if (identityCap < lowest) { - lowest = identityCap; + if (identityCap < index) { + index = identityCap; limiting = "identity"; } - return { level: levelFromIndex(lowest), limiting, perDimension }; + return { + level: levelFromIndex(index), + score, + limiting, + blocked: false, + perDimension, + }; } diff --git a/services/ppa/src/routes/submissions/[ename]/+page.svelte b/services/ppa/src/routes/submissions/[ename]/+page.svelte index 90e05bd9d..08a58c681 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.svelte +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -5,6 +5,7 @@ import ReviewThread from "$lib/ReviewThread.svelte"; import AssessmentMatrix from "$lib/AssessmentMatrix.svelte"; import IdentityPanel from "$lib/IdentityPanel.svelte"; + import AssessmentResult from "$lib/AssessmentResult.svelte"; import { computeLevel, type AccessLevel, @@ -23,6 +24,7 @@ let answers = $state([]); let assessmentOpen = $state(false); let overrideReason = $state(""); + let overrideOpen = $state(false); let computed = $derived( computeLevel(data.framework, answers, data.minimumIal), @@ -124,6 +126,12 @@ bind:answers /> + +

Issue a decision

@@ -174,14 +182,29 @@ {#if decision === "granted"} -

- - Access level +
+

Level to award

+

+ {level} {#if isOverride} - — overriding the assessment + + 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"} From a9a42405e03df3eb27720ed306b5e8bad13eb985 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:53:35 +0800 Subject: [PATCH 07/29] fix: stop a single L0 dimension collapsing the assessment score --- services/ppa/src/lib/AssessmentResult.svelte | 4 +-- services/ppa/src/lib/levels.spec.ts | 28 +++++++++++++++-- services/ppa/src/lib/levels.ts | 32 ++++++++++---------- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/services/ppa/src/lib/AssessmentResult.svelte b/services/ppa/src/lib/AssessmentResult.svelte index 0782fc605..cad5848f4 100644 --- a/services/ppa/src/lib/AssessmentResult.svelte +++ b/services/ppa/src/lib/AssessmentResult.svelte @@ -37,8 +37,8 @@

Calculation

- The level is the geometric mean of every dimension, so a weak row pulls - the result down sharply while a single middling one does not pin it. + 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.

{ expect(score).toBeLessThan(arithmetic); }); - it("holds the release at L0 when any dimension meets nothing above it", () => { + 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).toBe(0); - expect(result.level).toBe("L0"); + 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", "deployment-assurance"]; + 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", () => { diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts index d19f1355b..8f6a48718 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -92,9 +92,13 @@ export interface ComputedLevel { * * 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, and a row at zero takes the whole - * product to zero — without letting one middling row pin an otherwise strong - * release to its own value the way a strict minimum did. + * 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 (deployment assurance of "None" 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 @@ -133,20 +137,16 @@ export function computeLevel( return { level: null, score: 0, limiting, blocked: true, perDimension }; } - // Geometric mean. A single zero takes the product to zero, which is the - // intended behaviour: a dimension that meets nothing above L0 holds the - // whole release at L0. - const product = perDimension.reduce((acc, d) => acc * d.level, 1); + // Geometric mean over level + 1, shifted back, so a legitimate L0 row + // weighs heavily without annihilating the product. const score = - product === 0 - ? 0 - : Math.exp( - perDimension.reduce((acc, d) => acc + Math.log(d.level), 0) / - perDimension.length, - ); - - // exp(mean(ln 5)) lands a hair under 5, so floor alone would award L4 for a - // flawless assessment. Nudge past the float error before flooring. + 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); // The identity floor: the highest level whose required IAL is met. From 1522f8dae08e329b828f6682be5de2205446321c Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 16:59:05 +0800 Subject: [PATCH 08/29] feat: state each assessment option as an answer a reviewer can choose --- .../ppa/config/certification-framework.json | 140 +++++++++--------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json index cc820a674..379b9ffe1 100644 --- a/services/ppa/config/certification-framework.json +++ b/services/ppa/config/certification-framework.json @@ -43,11 +43,11 @@ "options": [ { "level": -1, - "label": "Not demonstrated" + "label": "Does not authenticate a user through W3DS or reach an eVault" }, { "level": 5, - "label": "Required — demonstrated" + "label": "Authenticates a user through W3DS and interacts with an eVault" } ] }, @@ -58,23 +58,23 @@ "options": [ { "level": 0, - "label": "None" + "label": "Not run — nobody has launched it" }, { "level": 1, - "label": "Basic" + "label": "Launched, and it broadly does what it claims" }, { "level": 3, - "label": "Reviewed" + "label": "Main flows exercised against the stated functionality and the areas it asks for" }, { "level": 4, - "label": "Extended" + "label": "Edge cases, failure handling and data flows exercised as well" }, { "level": 5, - "label": "High assurance" + "label": "Reviewed against a written specification, with the results recorded" } ] }, @@ -85,19 +85,19 @@ "options": [ { "level": -1, - "label": "IAL1 — anonymous" + "label": "IAL1 — at least one responsible person is anonymous" }, { "level": 0, - "label": "IAL2 — attested" + "label": "IAL2 — every responsible person is vouched for by someone already identified" }, { "level": 2, - "label": "IAL3 — passport-verified" + "label": "IAL3 — every responsible person is verified against a passport or equivalent eID" }, { "level": 5, - "label": "IAL4 — passport and independent attestation" + "label": "IAL4 — every responsible person is passport-verified and attested by three others who are" } ] }, @@ -108,23 +108,23 @@ "options": [ { "level": 0, - "label": "Responsible person recorded" + "label": "One responsible person is recorded" }, { "level": 1, - "label": "Normally the same person" + "label": "Developer and deployer are the same person" }, { "level": 2, - "label": "May differ; roles recorded" + "label": "They are different people, and each role is recorded" }, { "level": 4, - "label": "All key actors known" + "label": "Every key actor is known and named" }, { "level": 5, - "label": "Fully accountable chain" + "label": "Every role from author to operator is named and attributable" } ] }, @@ -135,23 +135,23 @@ "options": [ { "level": 0, - "label": "Optional — not declared" + "label": "Not asked — how the software was built is unrecorded" }, { "level": 1, - "label": "Declared" + "label": "Declared — the team stated whether it was conventional, AI-assisted, vibe-coded or mixed" }, { "level": 2, - "label": "Recorded" + "label": "Recorded — that declaration is written into this assessment for this release" }, { "level": 4, - "label": "Traceable" + "label": "Traceable — which parts were AI-generated can be identified from the commit history" }, { "level": 5, - "label": "Auditable" + "label": "Auditable — AI involvement is documented per change and can be checked independently" } ] }, @@ -162,11 +162,11 @@ "options": [ { "level": -1, - "label": "No" + "label": "Source is not available to PPA" }, { "level": 5, - "label": "Yes" + "label": "Source is available to PPA" } ] }, @@ -177,23 +177,23 @@ "options": [ { "level": 0, - "label": "None" + "label": "Nobody has read the code" }, { "level": 1, - "label": "Automated gross-error scan" + "label": "Automated scan only — tooling run for gross errors and obvious risks" }, { "level": 3, - "label": "Manual review each release" + "label": "A reviewer read this release for significant problems, suspicious behaviour and inconsistencies with what it claims to do" }, { "level": 4, - "label": "Extended" + "label": "That, plus dependencies, data handling and the sensitive paths" }, { "level": 5, - "label": "Extensive" + "label": "That, repeated by a second reviewer, with findings tracked to resolution" } ] }, @@ -204,23 +204,23 @@ "options": [ { "level": 1, - "label": "None" + "label": "Nobody has been interviewed" }, { "level": 2, - "label": "Owner, lead developer and deployer/operator" + "label": "Owner, lead developer and deployer/operator interviewed, where these are different people" }, { "level": 3, - "label": "All key responsible actors" + "label": "Every key responsible actor interviewed" }, { "level": 4, - "label": "Key actors and relevant technical leads" + "label": "Key actors and the relevant technical leads interviewed" }, { "level": 5, - "label": "All accountable leads including security and key custody" + "label": "Every accountable lead interviewed, including whoever holds the signing keys and runs security" } ] }, @@ -231,27 +231,27 @@ "options": [ { "level": -1, - "label": "Not recorded" + "label": "No commit history recorded" }, { "level": 1, - "label": "Recorded from the start" + "label": "Commit history recorded from the start" }, { "level": 2, - "label": "Reviewed" + "label": "That history has been looked at" }, { "level": 3, - "label": "Required and reviewed" + "label": "Required and reviewed — contributions traced to named people" }, { "level": 4, - "label": "Extended review" + "label": "Extended review of who changed what, and when" }, { "level": 5, - "label": "Fully auditable" + "label": "Fully auditable — every change attributable to an identified contributor" } ] }, @@ -262,23 +262,23 @@ "options": [ { "level": 1, - "label": "Recorded; no minimum" + "label": "References recorded; no minimum met" }, { "level": 2, - "label": "Emerging; no minimum" + "label": "Some references, but below the threshold" }, { "level": 3, - "label": "3 signed professional references" + "label": "Each responsible actor holds at least 3 signed professional references" }, { "level": 4, - "label": "5 references, including 2 recognised professionals" + "label": "At least 5, including 2 from recognised professionals" }, { "level": 5, - "label": "10 high-reputation professional or organisational references" + "label": "At least 10 from high-reputation professionals or organisations" } ] }, @@ -289,19 +289,19 @@ "options": [ { "level": 2, - "label": "No minimum" + "label": "No prior track record required at this level" }, { "level": 3, - "label": "1 prior Level 2+ app with positive eReputation; 50 authenticated user signals" + "label": "A prior Level 2+ application with positive standing, and around 50 authenticated signals" }, { "level": 4, - "label": "Established prior-app reputation and ~1,000 authenticated signals" + "label": "Established reputation from previous applications, and around 1,000 authenticated signals" }, { "level": 5, - "label": "Extensive strong portfolio and ~10,000 authenticated signals" + "label": "An extensive portfolio, and around 10,000 authenticated signals" } ] }, @@ -312,15 +312,15 @@ "options": [ { "level": 3, - "label": "None" + "label": "No independent review" }, { "level": 4, - "label": "1 independent signed review" + "label": "One signed review by a specialist or organisation independent of the applicant, stating what was reviewed, how, and what was found" }, { "level": 5, - "label": "Multiple independent reviews" + "label": "Several such reviews, from independent parties" } ] }, @@ -331,23 +331,23 @@ "options": [ { "level": 0, - "label": "Not relevant" + "label": "Not relevant — none claimed, and none needed here" }, { "level": 1, - "label": "May be recorded" + "label": "Claimed but unverified — mentioned, not checked" }, { "level": 2, - "label": "Considered" + "label": "A certificate from another body exists and has been looked at" }, { "level": 3, - "label": "Recognised" + "label": "Verified as genuine, and accepted in place of repeating that work" }, { "level": 5, - "label": "Important — present and weighted" + "label": "Verified, and the issuing body’s own standing weighed in the judgement" } ] }, @@ -358,27 +358,27 @@ "options": [ { "level": 0, - "label": "None" + "label": "Nothing ties what is running to this release" }, { "level": 1, - "label": "Basic declaration" + "label": "The team states the deployment matches the release" }, { "level": 2, - "label": "Reasonable check" + "label": "Version records line up with the release" }, { "level": 3, - "label": "Release provenance verified" + "label": "A signed deployment attests to this exact release" }, { "level": 4, - "label": "Strong assurance" + "label": "Signed artefacts or hashes match, with deployment logs" }, { "level": 5, - "label": "High assurance" + "label": "A reproducible build matches the deployed artefact" } ] }, @@ -389,27 +389,27 @@ "options": [ { "level": 0, - "label": "None" + "label": "Nothing asked about hosting, secrets or key custody" }, { "level": 1, - "label": "Minimal" + "label": "The team described where it runs and who can administer it" }, { "level": 2, - "label": "Basic" + "label": "That, and secrets and deployment keys are kept apart from the source" }, { "level": 3, - "label": "Reviewed where relevant" + "label": "Admin access, secret storage and change control checked where they matter" }, { "level": 4, - "label": "Strong" + "label": "Access control, key custody, backup and change control all reviewed and sound" }, { "level": 5, - "label": "Hardware-backed where appropriate" + "label": "Signing and deployment keys held in hardware or an HSM" } ] }, @@ -420,11 +420,11 @@ "options": [ { "level": 0, - "label": "Minimal statement" + "label": "Only a minimal statement is recorded" }, { "level": 5, - "label": "Yes — full findings recorded" + "label": "Full findings published with the certificate" } ] } From 5c15b241209b7e4f296358947d831d82c029c13a Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 17:04:48 +0800 Subject: [PATCH 09/29] fix: make every assessment option readable on its own --- .../ppa/config/certification-framework.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json index 379b9ffe1..580dd14d1 100644 --- a/services/ppa/config/certification-framework.json +++ b/services/ppa/config/certification-framework.json @@ -143,7 +143,7 @@ }, { "level": 2, - "label": "Recorded — that declaration is written into this assessment for this release" + "label": "The declaration is recorded in this assessment, for this release" }, { "level": 4, @@ -185,15 +185,15 @@ }, { "level": 3, - "label": "A reviewer read this release for significant problems, suspicious behaviour and inconsistencies with what it claims to do" + "label": "A reviewer read this release for significant problems and inconsistencies" }, { "level": 4, - "label": "That, plus dependencies, data handling and the sensitive paths" + "label": "A reviewer read the release, its dependencies, data handling and sensitive paths" }, { "level": 5, - "label": "That, repeated by a second reviewer, with findings tracked to resolution" + "label": "Two reviewers read it independently, with findings tracked to resolution" } ] }, @@ -220,7 +220,7 @@ }, { "level": 5, - "label": "Every accountable lead interviewed, including whoever holds the signing keys and runs security" + "label": "Every accountable lead interviewed, including security and key custody" } ] }, @@ -239,7 +239,7 @@ }, { "level": 2, - "label": "That history has been looked at" + "label": "The commit history has been looked at" }, { "level": 3, @@ -266,7 +266,7 @@ }, { "level": 2, - "label": "Some references, but below the threshold" + "label": "Each responsible actor holds 1 or 2 signed references" }, { "level": 3, @@ -316,7 +316,7 @@ }, { "level": 4, - "label": "One signed review by a specialist or organisation independent of the applicant, stating what was reviewed, how, and what was found" + "label": "One signed review by a specialist independent of the applicant, stating what was reviewed and found" }, { "level": 5, @@ -397,7 +397,7 @@ }, { "level": 2, - "label": "That, and secrets and deployment keys are kept apart from the source" + "label": "Secrets and deployment keys kept apart from the source" }, { "level": 3, From 6f0ee872c3024b10d6b9025e5d17462b28437d2f Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 17:08:20 +0800 Subject: [PATCH 10/29] fix: word code review as work done, not findings implied --- services/ppa/config/certification-framework.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/services/ppa/config/certification-framework.json b/services/ppa/config/certification-framework.json index 580dd14d1..c4bc4f521 100644 --- a/services/ppa/config/certification-framework.json +++ b/services/ppa/config/certification-framework.json @@ -181,19 +181,19 @@ }, { "level": 1, - "label": "Automated scan only — tooling run for gross errors and obvious risks" + "label": "Automated scan only — tooling run to catch gross errors and obvious risks" }, { "level": 3, - "label": "A reviewer read this release for significant problems and inconsistencies" + "label": "Manually reviewed this release, looking for problems and inconsistencies" }, { "level": 4, - "label": "A reviewer read the release, its dependencies, data handling and sensitive paths" + "label": "Manually reviewed the release, its dependencies, data handling and sensitive paths" }, { "level": 5, - "label": "Two reviewers read it independently, with findings tracked to resolution" + "label": "Manually reviewed by two people independently, with findings tracked to resolution" } ] }, From 8a54a369bba7be40918a7817e9b3241d56bf411d Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 17:11:08 +0800 Subject: [PATCH 11/29] fix: anchor the assessment drawer to the right edge --- .../routes/submissions/[ename]/+page.svelte | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/services/ppa/src/routes/submissions/[ename]/+page.svelte b/services/ppa/src/routes/submissions/[ename]/+page.svelte index 08a58c681..4c588cb45 100644 --- a/services/ppa/src/routes/submissions/[ename]/+page.svelte +++ b/services/ppa/src/routes/submissions/[ename]/+page.svelte @@ -1,5 +1,6 @@ + +{#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/DeploymentPanel.svelte b/services/pp-auth-demo/src/lib/DeploymentPanel.svelte new file mode 100644 index 000000000..246b15ef0 --- /dev/null +++ b/services/pp-auth-demo/src/lib/DeploymentPanel.svelte @@ -0,0 +1,218 @@ + + +
+
+
+

{deployment.name}

+

{deployment.platformName}

+

{deployment.blurb}

+
+
+ {#each deployment.certifiedDomains as domain (domain)} + {domain} + {/each} +
+
+ + {#if deployment.tampered} +

+ Evidence altered: {deployment.tampered}. Try reaching something and see + which step notices. +

+ {/if} + +
+
Release
+
{deployment.releaseTag}
+
Put here by
+
{deployment.deployerEname}
+
Reputation
+
+ {deployment.reputation === null ? "not scored" : deployment.reputation} +
+
+ +
+

+ Write something into the vault +

+ +
+ {#each domains as domain (domain.id)} + + {/each} +
+
+ + {#if decision} +

+ {decision.reason} +

+ {:else if chain && !chain.ok && lastDomain} +

+ {chain.links.find((link) => !link.ok)?.detail} +

+ {/if} + +
+
+

+ What it proved +

+ +
+ {#if chain} + + {:else} +

+ Nothing checked yet. Reach for something above, or check now. +

+ {/if} +
+ +
+ + Try to cheat + +
+
+ + + +
+
+ + + + + +
+
+
+
diff --git a/services/pp-auth-demo/src/lib/OwnerTerms.svelte b/services/pp-auth-demo/src/lib/OwnerTerms.svelte new file mode 100644 index 000000000..28c480d4b --- /dev/null +++ b/services/pp-auth-demo/src/lib/OwnerTerms.svelte @@ -0,0 +1,181 @@ + + +
+
+

Your terms

+

What you will deal with

+

+ The association says what a platform was found to be. You decide what + that is worth. Your answers are signed, so they travel with you and + anyone can check them — including the platform, before it bothers asking. +

+
+ +
+

+ The least you will accept +

+
+ {#each LEVELS as level (level.id)} + + {/each} +
+
+ +
+
+ + (saved = false)} + placeholder={reputationEngine} + /> +

+ Leave blank to ignore reputation entirely. +

+
+
+ + (saved = false)} + placeholder="No threshold" + /> +
+
+ +
+

+ Things nobody gets, whatever their certificate says +

+
+ {#each domains as domain (domain.id)} + + {/each} +
+
+ +
+ + {#if saved} + Signed and in force. + {/if} +
+ +
+ + The statement you signed + +
{JSON.stringify(policy, null, 2)}
+

Signature: {signature.signature}

+
+
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/access.ts b/services/pp-auth-demo/src/lib/server/access.ts new file mode 100644 index 000000000..32865ec4e --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/access.ts @@ -0,0 +1,108 @@ +/** + * One request through the whole mechanism: handshake, then the two gates. + * + * Every attempt runs the full handshake rather than caching a session. That is + * slower than a real deployment would be, and deliberate — the point of the + * demonstrator is that you can see the chain re-checked on each attempt and + * watch it fail the moment a link is broken. + */ + +import { + answerChallenge, + authorize, + verifyHandshake, + type AuthorizationDecision, + type ChainResult, +} from "@metastate-foundation/auth/platform"; +import { randomUUID } from "node:crypto"; +import { OWNER_ENAME, REPUTATION_ENGINE, type World } from "./world"; + +export interface AccessOutcome { + chain: ChainResult; + decision: AuthorizationDecision | null; + records: World["records"]; +} + +export async function attemptAccess( + current: World, + deploymentId: string, + domain: string, + write?: { kind: string; body: string }, +): Promise { + const deployment = current.deployments.get(deploymentId); + if (!deployment) throw new Error(`unknown deployment ${deploymentId}`); + + const challenge = current.challenges.issue(OWNER_ENAME); + const response = await answerChallenge(deployment.identity, challenge); + const chain = await verifyHandshake(response, { + audience: OWNER_ENAME, + registryBaseUrl: "demo://registry", + registryJwksUri: "demo://registry/.well-known/jwks.json", + verifyWalletSignature: current.roots.verifyWalletSignature, + resolveJwks: current.resolveJwks, + store: current.challenges, + }); + + if (!chain.ok || !chain.claim) { + record(current, deployment.id, deployment.identity.evidence.deploymentName, domain, { + allowed: false, + code: "handshake-failed", + reason: `Could not establish who this is: ${chain.links.find((link) => !link.ok)?.detail ?? "the chain of trust did not hold"}`, + }); + return { chain, decision: null, records: [] }; + } + + const score = current.reputation.get(chain.claim.platformEname); + const decision = authorize(current.policy.statement, { + claim: chain.claim, + domain, + reputation: + score === undefined ? null : { engine: REPUTATION_ENGINE, score }, + }); + record( + current, + deployment.id, + chain.claim.platformName, + domain, + decision, + ); + + if (!decision.allowed) return { chain, decision, records: [] }; + + if (write) { + current.records.unshift({ + id: randomUUID(), + domain, + kind: write.kind, + body: write.body, + writtenBy: chain.claim.platformName, + at: new Date().toISOString(), + }); + } + + return { + chain, + decision, + records: current.records.filter((entry) => entry.domain === domain), + }; +} + +function record( + current: World, + deploymentId: string, + deploymentName: string, + domain: string, + decision: { allowed: boolean; reason: string; code: string }, +): void { + current.attempts.unshift({ + id: randomUUID(), + at: new Date().toISOString(), + deploymentId, + deploymentName, + domain, + allowed: decision.allowed, + reason: decision.reason, + code: decision.code, + }); + current.attempts.splice(50); +} diff --git a/services/pp-auth-demo/src/lib/server/world.ts b/services/pp-auth-demo/src/lib/server/world.ts new file mode 100644 index 000000000..6a2ea7cb8 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/world.ts @@ -0,0 +1,245 @@ +/** + * The demonstrator's world: two deployments, one vault, one owner. + * + * Everything here is genuine cryptography checked by the real PP Auth + * verifier. What is simulated is the *setting*, not the mechanism: the keys + * standing in for the deployer's wallet, the registry and the association are + * generated in this process rather than held by those parties, so the + * demonstrator can run on a laptop with nothing else switched on. + * + * State is anchored on globalThis because Vite's dev server evaluates a module + * once per import graph, and a world that exists twice would issue challenges + * from one copy and try to redeem them in the other. + */ + +import { + accessPolicyPayload, + createChallengeStore, + defaultAccessPolicy, + generateKeyPair, + signP256, + verifyP256, + type AccessPolicyStatement, + type ChallengeStore, + type DeploymentIdentity, + type SignedAccessPolicy, +} from "@metastate-foundation/auth/platform"; +// Minting evidence from local keys is a demonstration and test facility. It is +// deliberately a separate entry point so it can never be reached by accident +// from code that verifies real deployments. +import { + createTrustRoots, + mintDeployment, + type TrustRoots, +} from "@metastate-foundation/auth/platform/scenario"; +import { createLocalJWKSet } from "jose"; + +/** The person whose eVault the demonstration is about. */ +export const OWNER_ENAME = "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f"; + +export const ISSUER_JWKS_URI = "demo://association/.well-known/jwks.json"; +export const REGISTRY_JWKS_URI = "demo://registry/.well-known/jwks.json"; +export const REPUTATION_ENGINE = "@ereputation.w3ds"; + +export interface VaultRecord { + id: string; + domain: string; + kind: string; + body: string; + writtenBy: string; + at: string; +} + +export interface AttemptLog { + id: string; + at: string; + deploymentId: string; + deploymentName: string; + domain: string; + allowed: boolean; + reason: string; + code: string; +} + +export interface DemoDeployment { + id: string; + /** How the platform describes itself, for the card. */ + blurb: string; + identity: DeploymentIdentity; + /** Set when the operator has tampered with the evidence, so the UI can say so. */ + tampered: string | null; + pristine: DeploymentIdentity; +} + +export interface World { + roots: TrustRoots; + challenges: ChallengeStore; + deployments: Map; + records: VaultRecord[]; + attempts: AttemptLog[]; + policy: SignedAccessPolicy; + ownerKey: { publicKey: string; privateKey: string }; + /** Reputation the demo's engine reports, keyed by platform eName. */ + reputation: Map; + resolveJwks: (uri: string) => ReturnType; +} + +const KEY = Symbol.for("pp-auth-demo.world"); +const store = globalThis as typeof globalThis & { [KEY]?: Promise }; + +const CHATTERBOX = "@11111111-1111-4111-8111-111111111111"; +const LEDGERLY = "@22222222-2222-4222-8222-222222222222"; + +export async function signPolicy( + statement: AccessPolicyStatement, + ownerKey: { privateKey: string }, +): Promise { + const payload = accessPolicyPayload(statement); + return { + statement, + payload, + signature: await signP256(ownerKey.privateKey, payload), + signer: statement.subject, + }; +} + +async function build(): Promise { + const deployerEname = "@d0000000-0000-4000-8000-00000000dep1"; + const roots = await createTrustRoots(deployerEname); + const registryKeys = createLocalJWKSet(roots.registry.jwks); + const associationKeys = createLocalJWKSet(roots.association.jwks); + + const shared = { + issuerJwksUri: ISSUER_JWKS_URI, + registryJwksUri: REGISTRY_JWKS_URI, + reviewedByEName: OWNER_ENAME, + }; + + const [chatterbox, ledgerly] = await Promise.all([ + mintDeployment(roots, { + ...shared, + platformEname: CHATTERBOX, + platformName: "Chatterbox", + deploymentName: "chatterbox-eu", + environment: "production", + version: "1.4.0", + releaseTag: "v1.4.0", + commitSha: "4f9c1a77b2e3d5086a1c9f2b3d4e5f60718293a4", + repository: "https://gitw3.example/acme/chatterbox", + requestedDomains: ["social", "communication"], + level: "L3", + }), + mintDeployment(roots, { + ...shared, + platformEname: LEDGERLY, + platformName: "Ledgerly", + deploymentName: "ledgerly-eu", + environment: "production", + version: "2.1.3", + releaseTag: "v2.1.3", + commitSha: "9b1e2d3c4a5f60718293a4b5c6d7e8f90a1b2c3d", + repository: "https://gitw3.example/acme/ledgerly", + requestedDomains: ["finance"], + level: "L4", + }), + ]); + + const deployments = new Map([ + [ + "chatterbox", + { + id: "chatterbox", + blurb: "A social platform. Posts, feeds, and direct messages.", + identity: chatterbox.identity, + pristine: structuredClone(chatterbox.identity), + tampered: null, + }, + ], + [ + "ledgerly", + { + id: "ledgerly", + blurb: "A finance platform. Accounts, ledgers, and payments.", + identity: ledgerly.identity, + pristine: structuredClone(ledgerly.identity), + tampered: null, + }, + ], + ]); + + const ownerKey = await generateKeyPair(); + const policy = await signPolicy( + { + ...defaultAccessPolicy(OWNER_ENAME), + minimumLevel: "L2", + issuedAt: new Date().toISOString(), + nonce: "initial", + }, + ownerKey, + ); + + return { + roots, + challenges: createChallengeStore(), + deployments, + records: seedRecords(), + attempts: [], + policy, + ownerKey, + reputation: new Map([ + [CHATTERBOX, 62], + [LEDGERLY, 31], + ]), + resolveJwks: (uri) => + uri === ISSUER_JWKS_URI ? associationKeys : registryKeys, + }; +} + +function seedRecords(): VaultRecord[] { + const at = new Date().toISOString(); + return [ + { + id: "seed-social", + domain: "social", + kind: "Social media post", + body: "Spent the morning on the allotment. Tomatoes finally coming through.", + writtenBy: OWNER_ENAME, + at, + }, + { + id: "seed-finance", + domain: "finance", + kind: "Account", + body: "Current account · balance £4,182.60 · sort 04-00-04", + writtenBy: OWNER_ENAME, + at, + }, + { + id: "seed-comms", + domain: "communication", + kind: "Message", + body: "Are we still on for Thursday?", + writtenBy: OWNER_ENAME, + at, + }, + ]; +} + +export function world(): Promise { + if (!store[KEY]) store[KEY] = build(); + return store[KEY]; +} + +/** Verifies a signature made by the demo owner's stand-in wallet. */ +export async function verifyOwnerSignature( + current: World, + signature: string, + payload: string, +): Promise { + return verifyP256(current.ownerKey.publicKey, signature, payload); +} + +export async function resetWorld(): Promise { + store[KEY] = build(); + return store[KEY]; +} 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..ff9e9ab80 --- /dev/null +++ b/services/pp-auth-demo/src/routes/+layout.svelte @@ -0,0 +1,28 @@ + + +
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..104244342 --- /dev/null +++ b/services/pp-auth-demo/src/routes/+page.server.ts @@ -0,0 +1,44 @@ +import { DOMAINS } from "$lib/domains"; +import { OWNER_ENAME, REPUTATION_ENGINE, world } from "$lib/server/world"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async () => { + const current = await world(); + + const deployments = [...current.deployments.values()].map((deployment) => { + const evidence = deployment.identity.evidence; + return { + id: deployment.id, + blurb: deployment.blurb, + name: evidence.deploymentName, + platformName: evidence.submissionProof.statement.platformName, + platformEname: evidence.platformEname, + deploymentEname: evidence.deploymentEname, + environment: evidence.environment, + version: evidence.version, + releaseTag: evidence.releaseTag, + commitSha: evidence.commitSha, + publicKey: evidence.publicKey, + deployerEname: evidence.deployerEname, + certifiedDomains: evidence.submissionProof.statement.domains, + repository: evidence.submissionProof.statement.repository, + reputation: current.reputation.get(evidence.platformEname) ?? null, + tampered: deployment.tampered, + }; + }); + + return { + owner: OWNER_ENAME, + reputationEngine: REPUTATION_ENGINE, + domains: DOMAINS, + deployments, + policy: current.policy.statement, + policySignature: { + payload: current.policy.payload, + signature: current.policy.signature, + signer: current.policy.signer, + }, + records: current.records, + attempts: current.attempts, + }; +}; diff --git a/services/pp-auth-demo/src/routes/+page.svelte b/services/pp-auth-demo/src/routes/+page.svelte new file mode 100644 index 000000000..c9a3a1284 --- /dev/null +++ b/services/pp-auth-demo/src/routes/+page.svelte @@ -0,0 +1,127 @@ + + +
+
+

The demonstration

+

+ Two platforms want your data. Only one of them should get each thing. +

+

+ Chatterbox is a social platform. Ledgerly handles money. Both are + running, both are certified, and both will now try to reach everything + in your vault — your posts, your messages, your accounts, your health + records. Watch what each one is actually allowed to touch, and why. +

+

+ Nothing here is enforced by a list of platform names. Each one proves, + from scratch on every attempt, which release it is running and what + that release was certified for. A social platform has no way to say + the word “finance”: it is not in its certificate, and nothing it can + present will put it there. +

+ +
+ +
+ {#each data.deployments as deployment, index (deployment.id)} + + {/each} +
+ + {#key data.policy.nonce} + + {/key} + +
+
+
+

Your vault

+

What is in there

+
+
    + {#each data.records as record (record.id)} +
  • +
    + {record.domain} + + {record.writtenBy === data.owner ? "you" : record.writtenBy} + +
    +

    {record.body}

    +
  • + {/each} +
+
+ +
+
+

What happened

+

Every attempt, kept

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

+ Nothing has tried to reach your data yet. +

+ {:else} +
    + {#each data.attempts as attempt (attempt.id)} +
  • + +
    +

    {attempt.reason}

    +

    + {new Date(attempt.at).toLocaleTimeString()} +

    +
    +
  • + {/each} +
+ {/if} +
+
+ +
+

Honesty note

+

+ The signatures on this page are real and are checked by the same code + that checks a live deployment. What is simulated is who holds the + keys: the wallet, the registry and the association are stood in for by + keys generated in this process, so the demonstration runs on its own. + A chain that verifies here proves the mechanism works — not that any + particular platform is trustworthy. +

+
+
diff --git a/services/pp-auth-demo/src/routes/api/access/+server.ts b/services/pp-auth-demo/src/routes/api/access/+server.ts new file mode 100644 index 000000000..850e5575c --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/access/+server.ts @@ -0,0 +1,30 @@ +import { json } from "@sveltejs/kit"; +import { attemptAccess } from "$lib/server/access"; +import { world } from "$lib/server/world"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json()) as { + deploymentId?: string; + domain?: string; + kind?: string; + text?: string; + }; + const current = await world(); + const text = (body.text ?? "").trim(); + + try { + const outcome = await attemptAccess( + current, + String(body.deploymentId), + String(body.domain), + text ? { kind: body.kind || "Note", body: text } : undefined, + ); + return json(outcome); + } catch (error) { + return json( + { error: error instanceof Error ? error.message : "failed" }, + { status: 400 }, + ); + } +}; diff --git a/services/pp-auth-demo/src/routes/api/handshake/+server.ts b/services/pp-auth-demo/src/routes/api/handshake/+server.ts new file mode 100644 index 000000000..a91e0ba1a --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/handshake/+server.ts @@ -0,0 +1,28 @@ +import { json } from "@sveltejs/kit"; +import { + answerChallenge, + verifyHandshake, +} from "@metastate-foundation/auth/platform"; +import { OWNER_ENAME, world } from "$lib/server/world"; +import type { RequestHandler } from "./$types"; + +/** Runs the handshake alone, so the chain can be inspected without touching data. */ +export const POST: RequestHandler = async ({ request }) => { + const { deploymentId } = (await request.json()) as { deploymentId?: string }; + const current = await world(); + const deployment = current.deployments.get(String(deploymentId)); + if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 }); + + const challenge = current.challenges.issue(OWNER_ENAME); + const response = await answerChallenge(deployment.identity, challenge); + const chain = await verifyHandshake(response, { + audience: OWNER_ENAME, + registryBaseUrl: "demo://registry", + registryJwksUri: "demo://registry/.well-known/jwks.json", + verifyWalletSignature: current.roots.verifyWalletSignature, + resolveJwks: current.resolveJwks, + store: current.challenges, + }); + + return json({ chain, challenge }); +}; diff --git a/services/pp-auth-demo/src/routes/api/policy/+server.ts b/services/pp-auth-demo/src/routes/api/policy/+server.ts new file mode 100644 index 000000000..4104061d0 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/policy/+server.ts @@ -0,0 +1,58 @@ +import { json } from "@sveltejs/kit"; +import { + CERTIFICATION_LEVELS, + defaultAccessPolicy, + verifyAccessPolicy, + type CertificationLevel, +} from "@metastate-foundation/auth/platform"; +import { OWNER_ENAME, signPolicy, verifyOwnerSignature, world } from "$lib/server/world"; +import type { RequestHandler } from "./$types"; + +/** + * Records the owner's terms as a signed statement. + * + * The signature is made here with the owner's stand-in key; in a running + * system this is where the eID wallet signs. It is verified immediately after + * signing, so a statement that could not be checked never becomes the policy. + */ +export const POST: RequestHandler = async ({ request }) => { + const body = (await request.json()) as Record; + const current = await world(); + + const level = String(body.minimumLevel ?? "") as CertificationLevel; + if (!CERTIFICATION_LEVELS.includes(level)) { + return json({ error: "Unknown level" }, { status: 400 }); + } + const strings = (value: unknown): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; + const minimumReputation = + body.minimumReputation === null || body.minimumReputation === "" + ? null + : Number(body.minimumReputation); + if (minimumReputation !== null && !Number.isFinite(minimumReputation)) { + return json({ error: "Reputation threshold must be a number" }, { status: 400 }); + } + + const statement = { + ...defaultAccessPolicy(OWNER_ENAME), + minimumLevel: level, + reputationEngine: + typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "", + minimumReputation, + allowedDomains: body.allowedDomains === null ? null : strings(body.allowedDomains), + deniedDomains: strings(body.deniedDomains), + issuedAt: new Date().toISOString(), + nonce: crypto.randomUUID(), + }; + + const signed = await signPolicy(statement, current.ownerKey); + const valid = await verifyAccessPolicy(signed, (_signer, signature, payload) => + verifyOwnerSignature(current, signature, payload), + ); + if (!valid) { + return json({ error: "The signed terms did not verify" }, { status: 500 }); + } + + current.policy = signed; + return json({ policy: signed.statement, payload: signed.payload, signature: signed.signature }); +}; diff --git a/services/pp-auth-demo/src/routes/api/reset/+server.ts b/services/pp-auth-demo/src/routes/api/reset/+server.ts new file mode 100644 index 000000000..a91debee3 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/reset/+server.ts @@ -0,0 +1,8 @@ +import { json } from "@sveltejs/kit"; +import { resetWorld } from "$lib/server/world"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async () => { + await resetWorld(); + return json({ ok: true }); +}; diff --git a/services/pp-auth-demo/src/routes/api/tamper/+server.ts b/services/pp-auth-demo/src/routes/api/tamper/+server.ts new file mode 100644 index 000000000..d437b9a44 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/tamper/+server.ts @@ -0,0 +1,90 @@ +import { json } from "@sveltejs/kit"; +import { resetWorld, world } from "$lib/server/world"; +import type { RequestHandler } from "./$types"; + +/** + * Breaks one link on purpose. + * + * A chain that only ever passes demonstrates nothing. Each of these edits is + * something an attacker would plausibly try — presenting a key you do not + * hold, widening your own authorisation, borrowing a better-certified + * release's paperwork — and each should be caught by exactly one link. + */ +const EDITS: Record< + string, + { label: string; expect: string; apply: (deployment: any, other: any, value: string) => void } +> = { + publicKey: { + label: "Present a different public key", + expect: "possession", + apply: (deployment, _other, value) => { + deployment.identity.evidence.publicKey = value; + deployment.identity.evidence.deploymentKeyDocument.data.publicKey = value; + }, + }, + environment: { + label: "Promote itself from staging to production", + expect: "deployment-authorised", + apply: (deployment, _other, value) => { + deployment.identity.evidence.deploymentKeyDocument.data.environment = + value || "production-plus"; + }, + }, + versionDocument: { + label: "Borrow the other platform's version document", + expect: "bundle-integrity", + apply: (deployment, other) => { + deployment.identity.evidence.softwareVersionDocument = + structuredClone(other.identity.evidence.softwareVersionDocument); + }, + }, + versionEname: { + label: "Point at a different release", + expect: "version-identity", + apply: (deployment, _other, value) => { + deployment.identity.evidence.versionEname = + value || "@99999999-9999-4999-8999-999999999999"; + }, + }, + certificate: { + label: "Borrow the other platform's certificate", + expect: "accreditation", + apply: (deployment, other) => { + deployment.identity.evidence.accreditationJws = + other.identity.evidence.accreditationJws; + }, + }, +}; + +export const POST: RequestHandler = async ({ request }) => { + const { deploymentId, edit, value } = (await request.json()) as { + deploymentId?: string; + edit?: string; + value?: string; + }; + const current = await world(); + const deployment = current.deployments.get(String(deploymentId)); + if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 }); + + if (edit === "restore") { + deployment.identity = structuredClone(deployment.pristine); + deployment.tampered = null; + return json({ tampered: null }); + } + + const change = EDITS[String(edit)]; + if (!change) return json({ error: "Unknown edit" }, { status: 400 }); + + const other = [...current.deployments.values()].find( + (entry) => entry.id !== deployment.id, + ); + change.apply(deployment, other, String(value ?? "")); + deployment.tampered = change.label; + + return json({ tampered: change.label, expect: change.expect }); +}; + +export const DELETE: RequestHandler = async () => { + await resetWorld(); + return json({ ok: true }); +}; 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()], +}); From 8f84cfc4ceeb2deaabc8c788f610016d0e595de3 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 17:44:46 +0800 Subject: [PATCH 15/29] docs: document platform authentication and owner terms Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 46 +++++++ docs/docs/Post Platform Guide/pp-auth.md | 116 ++++++++++++++++++ docs/docs/W3DS Basics/Access-Policy.md | 52 ++++++++ docs/docs/W3DS Basics/Links.md | 2 +- .../W3DS Protocol/Platform-Authentication.md | 85 +++++++++++++ 5 files changed, 300 insertions(+), 1 deletion(-) create mode 100644 docs/docs/Post Platform Guide/pp-auth-demonstrator.md create mode 100644 docs/docs/Post Platform Guide/pp-auth.md create mode 100644 docs/docs/W3DS Basics/Access-Policy.md create mode 100644 docs/docs/W3DS Protocol/Platform-Authentication.md 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..f3d007484 --- /dev/null +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -0,0 +1,46 @@ +--- +sidebar_position: 9 +--- + +# PP Auth demonstrator + +A running demonstration of platform authentication and domain separation. Two platforms, one vault, and every attempt to reach data shown with the reason it succeeded or failed. + +```bash +pnpm --filter pp-auth-demo dev +``` + +Then open **http://localhost:4310**. Nothing else needs to be running — no database, no registry, no eVault. + +## What it shows + +**Chatterbox** is a social platform, certified L3 for `social` and `communication`. **Ledgerly** handles money, certified L4 for `finance`. Both are live, both will try to reach everything in the vault. + +Point either one at a domain it was not certified for and it is refused — with a sentence saying so, not a status code. The refusal does not come from a list of platform names: it comes from the certificate the deployment presented, which does not name that domain and cannot be made to. + +**Your terms** sets the owner's side: the minimum level, whose reputation scores count and what score they must reach, and any domain refused outright. Signing produces a real signature over a real statement, which is verified before it takes effect. Raise the bar to L4 and Chatterbox stops being allowed anything; require a reputation of 50 and Ledgerly does, on the scores the demo's engine reports. + +**Try to cheat** is where the mechanism is visible. Each edit breaks exactly one link: + +| Edit | Fails at | +|---|---| +| Present a different public key — paste your own | Possession | +| Widen its own authorisation | Deployment authorised | +| Borrow the other platform's version document | Bundle integrity | +| Point at a different release | Version identity | +| Borrow the other platform's certificate | Accreditation | + +The chain trace re-runs on every attempt, so you can watch a link go red and read why. + +## What is real and what is not + +The signatures are real — P-256 and ES256, verified by exactly the same code that verifies a live deployment. The tampering really does fail, for the reason shown. + +What is simulated is who holds the keys. The deployer's wallet, the registry and the association are stood in for by keys generated in the demo process, so it runs on its own. A chain that verifies here proves the mechanism works. It proves nothing about any particular platform, which is what the real roots are for. + +The minting facility lives at `@metastate-foundation/auth/platform/scenario`, deliberately behind a separate entry point so it cannot be reached by accident from code that verifies real deployments. + +## 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..62cddef5d --- /dev/null +++ b/docs/docs/W3DS Basics/Access-Policy.md @@ -0,0 +1,52 @@ +--- +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 URL. Blank means reputation is not consulted at all. | +| `minimumReputation` | The score that engine must report for the platform. Null means no threshold. | +| `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`. + +## 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. From 96c8175ffb499d8be6b75b1316ce2ce00595886f Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 17:58:15 +0800 Subject: [PATCH 16/29] fix: canonicalise release statements in the order the wallet signed Verified against live congo-basin proofs: the digest is over GitW3's struct field order, not the order a statement arrives in. Every genuine release was being rejected. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- packages/auth/src/platform/bytes.spec.ts | 76 ++++++++++++++++++++++++ packages/auth/src/platform/bytes.ts | 41 +++++++++++++ packages/auth/src/platform/chain.ts | 8 ++- packages/auth/src/platform/index.ts | 1 + packages/auth/src/platform/scenario.ts | 12 +++- 5 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/auth/src/platform/bytes.spec.ts 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 index ca60b87b8..ffc08d1c4 100644 --- a/packages/auth/src/platform/bytes.ts +++ b/packages/auth/src/platform/bytes.ts @@ -188,3 +188,44 @@ export function signatureCandidates(value: string): Uint8Array[] { 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.ts b/packages/auth/src/platform/chain.ts index 4bdd0ad92..f80ba8049 100644 --- a/packages/auth/src/platform/chain.ts +++ b/packages/auth/src/platform/chain.ts @@ -22,6 +22,7 @@ import { createHash } from "node:crypto"; import { createRemoteJWKSet, jwtVerify } from "jose"; import { bindingDocumentHash, + canonicalSubmissionStatement, sha256Base64Url, stableStringify, } from "./bytes.js"; @@ -340,7 +341,12 @@ export async function verifyDeploymentChain( let authorshipDetail = "The release carried no submission proof."; if (proof?.statement) { const canonical = - SUBMISSION_PREFIX + sha256Base64Url(JSON.stringify(proof.statement)); + 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) { diff --git a/packages/auth/src/platform/index.ts b/packages/auth/src/platform/index.ts index 99ac13b1f..a243f27cf 100644 --- a/packages/auth/src/platform/index.ts +++ b/packages/auth/src/platform/index.ts @@ -1,5 +1,6 @@ export { bindingDocumentHash, + canonicalSubmissionStatement, decodeBase58, decodePublicKey, derSignatureToRaw, diff --git a/packages/auth/src/platform/scenario.ts b/packages/auth/src/platform/scenario.ts index e82327f8a..c163b7be8 100644 --- a/packages/auth/src/platform/scenario.ts +++ b/packages/auth/src/platform/scenario.ts @@ -17,7 +17,11 @@ import { randomUUID } from "node:crypto"; import { SignJWT, exportJWK, generateKeyPair as generateJwkPair } from "jose"; import type { JWK, KeyLike } from "jose"; -import { bindingDocumentHash, sha256Base64Url } from "./bytes.js"; +import { + bindingDocumentHash, + canonicalSubmissionStatement, + sha256Base64Url, +} from "./bytes.js"; import { softwareVersionEName } from "./chain.js"; import { generateKeyPair, signP256, verifyP256 } from "./p256.js"; import type { @@ -190,7 +194,11 @@ export async function mintDeployment( issuedAt: timestamp, nonce: randomUUID(), }; - const payload = SUBMISSION_PREFIX + sha256Base64Url(JSON.stringify(statement)); + const payload = + SUBMISSION_PREFIX + + sha256Base64Url( + canonicalSubmissionStatement(statement as unknown as Record), + ); const keyBindingCertificate = await new SignJWT({ ename: roots.wallet.ename, publicKey: roots.wallet.publicKey, From e1a892e5b0c1f309a624492d5151ab7f2fdabca0 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:08:28 +0800 Subject: [PATCH 17/29] fix: show what the evidence supported when the identity floor caps it A geometric mean of 3.13 beside an award of L2 reads as an arithmetic mistake. It is the IAL4 requirement for L3 and above, so say that. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- services/ppa/src/lib/AssessmentResult.svelte | 15 +++++++++++++-- services/ppa/src/lib/levels.spec.ts | 16 ++++++++++++++++ services/ppa/src/lib/levels.ts | 17 ++++++++++++++++- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/services/ppa/src/lib/AssessmentResult.svelte b/services/ppa/src/lib/AssessmentResult.svelte index cad5848f4..6acf99e6c 100644 --- a/services/ppa/src/lib/AssessmentResult.svelte +++ b/services/ppa/src/lib/AssessmentResult.svelte @@ -53,6 +53,15 @@

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

Evidence supports

+

+ {result.scoredLevel} +

+
+
+ {/if}

Computed level

@@ -68,8 +77,10 @@

{:else if identityCapped}

- Capped at {result.level} by the identity floor — the weakest - accountable actor is {minimumIal}. + 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}

diff --git a/services/ppa/src/lib/levels.spec.ts b/services/ppa/src/lib/levels.spec.ts index 79119ac16..ec6bb4429 100644 --- a/services/ppa/src/lib/levels.spec.ts +++ b/services/ppa/src/lib/levels.spec.ts @@ -103,6 +103,22 @@ describe("computeLevel", () => { 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(); }); diff --git a/services/ppa/src/lib/levels.ts b/services/ppa/src/lib/levels.ts index 8f6a48718..8ab64c1cd 100644 --- a/services/ppa/src/lib/levels.ts +++ b/services/ppa/src/lib/levels.ts @@ -78,6 +78,12 @@ 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. */ @@ -134,7 +140,14 @@ export function computeLevel( } if (blocked || perDimension.length === 0) { - return { level: null, score: 0, limiting, blocked: true, perDimension }; + return { + level: null, + score: 0, + scoredLevel: null, + limiting, + blocked: true, + perDimension, + }; } // Geometric mean over level + 1, shifted back, so a legitimate L0 row @@ -148,6 +161,7 @@ export function computeLevel( // 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; @@ -166,6 +180,7 @@ export function computeLevel( return { level: levelFromIndex(index), score, + scoredLevel, limiting, blocked: false, perDimension, From ff07f2f9c3985079135ea1d9cff5a42a6449661d Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:13:13 +0800 Subject: [PATCH 18/29] feat: run the demonstrator on real platforms and your own eVault MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the minted Chatterbox/Ledgerly world with what is actually on the network: platforms and certificates from awareness, deployments and their binding documents from the eVaults holding them, and the signed-in owner's own records grouped by the domain each schema declares. Split into Platforms, Your data and Your terms. Terms are signed by the real wallet — the signing session id is the statement's canonical payload, so the signature verifies standalone — and published to the owner's eVault. Possession reports 'not attempted' without a key rather than claiming a check failed that was never made. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 45 ++-- services/pp-auth-demo/package.json | 6 +- services/pp-auth-demo/src/app.d.ts | 2 +- services/pp-auth-demo/src/hooks.server.ts | 46 ++++ .../src/lib/DeploymentPanel.svelte | 218 ---------------- .../pp-auth-demo/src/lib/DeploymentRow.svelte | 133 ++++++++++ .../{OwnerTerms.svelte => TermsForm.svelte} | 161 +++++++----- services/pp-auth-demo/src/lib/server/aaas.ts | 174 +++++++++++++ .../pp-auth-demo/src/lib/server/access.ts | 108 -------- services/pp-auth-demo/src/lib/server/chain.ts | 198 ++++++++++++++ services/pp-auth-demo/src/lib/server/data.ts | 87 +++++++ .../pp-auth-demo/src/lib/server/domains.ts | 66 +++++ services/pp-auth-demo/src/lib/server/env.ts | 53 ++++ .../pp-auth-demo/src/lib/server/evault.ts | 196 ++++++++++++++ services/pp-auth-demo/src/lib/server/keys.ts | 29 +++ .../pp-auth-demo/src/lib/server/ontology.ts | 35 +++ .../pp-auth-demo/src/lib/server/policy.ts | 131 ++++++++++ .../pp-auth-demo/src/lib/server/session.ts | 119 +++++++++ services/pp-auth-demo/src/lib/server/token.ts | 55 ++++ services/pp-auth-demo/src/lib/server/world.ts | 245 ------------------ .../pp-auth-demo/src/routes/+layout.server.ts | 6 + .../pp-auth-demo/src/routes/+layout.svelte | 48 +++- .../pp-auth-demo/src/routes/+page.server.ts | 42 +-- services/pp-auth-demo/src/routes/+page.svelte | 127 --------- .../src/routes/api/access/+server.ts | 30 --- .../src/routes/api/auth/+server.ts | 22 ++ .../src/routes/api/auth/logout/+server.ts | 8 + .../src/routes/api/auth/offer/+server.ts | 5 + .../api/auth/session/[session]/+server.ts | 16 ++ .../src/routes/api/handshake/+server.ts | 28 -- .../src/routes/api/key/+server.ts | 26 ++ .../src/routes/api/policy/+server.ts | 58 ----- .../src/routes/api/reset/+server.ts | 8 - .../src/routes/api/tamper/+server.ts | 90 ------- .../src/routes/api/terms/+server.ts | 59 +++++ .../src/routes/api/terms/status/+server.ts | 42 +++ .../src/routes/api/verify/+server.ts | 33 +++ .../src/routes/data/+page.server.ts | 65 +++++ .../pp-auth-demo/src/routes/data/+page.svelte | 111 ++++++++ .../src/routes/login/+page.svelte | 63 +++++ .../src/routes/platforms/+page.server.ts | 102 ++++++++ .../src/routes/platforms/+page.svelte | 82 ++++++ .../src/routes/terms/+page.server.ts | 12 + .../src/routes/terms/+page.svelte | 49 ++++ services/pp-auth-demo/src/svelte-qrcode.d.ts | 20 ++ 45 files changed, 2203 insertions(+), 1056 deletions(-) create mode 100644 services/pp-auth-demo/src/hooks.server.ts delete mode 100644 services/pp-auth-demo/src/lib/DeploymentPanel.svelte create mode 100644 services/pp-auth-demo/src/lib/DeploymentRow.svelte rename services/pp-auth-demo/src/lib/{OwnerTerms.svelte => TermsForm.svelte} (50%) create mode 100644 services/pp-auth-demo/src/lib/server/aaas.ts delete mode 100644 services/pp-auth-demo/src/lib/server/access.ts create mode 100644 services/pp-auth-demo/src/lib/server/chain.ts create mode 100644 services/pp-auth-demo/src/lib/server/data.ts create mode 100644 services/pp-auth-demo/src/lib/server/domains.ts create mode 100644 services/pp-auth-demo/src/lib/server/env.ts create mode 100644 services/pp-auth-demo/src/lib/server/evault.ts create mode 100644 services/pp-auth-demo/src/lib/server/keys.ts create mode 100644 services/pp-auth-demo/src/lib/server/ontology.ts create mode 100644 services/pp-auth-demo/src/lib/server/policy.ts create mode 100644 services/pp-auth-demo/src/lib/server/session.ts create mode 100644 services/pp-auth-demo/src/lib/server/token.ts delete mode 100644 services/pp-auth-demo/src/lib/server/world.ts create mode 100644 services/pp-auth-demo/src/routes/+layout.server.ts delete mode 100644 services/pp-auth-demo/src/routes/+page.svelte delete mode 100644 services/pp-auth-demo/src/routes/api/access/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/auth/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/auth/logout/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/auth/offer/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/auth/session/[session]/+server.ts delete mode 100644 services/pp-auth-demo/src/routes/api/handshake/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/key/+server.ts delete mode 100644 services/pp-auth-demo/src/routes/api/policy/+server.ts delete mode 100644 services/pp-auth-demo/src/routes/api/reset/+server.ts delete mode 100644 services/pp-auth-demo/src/routes/api/tamper/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/terms/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/terms/status/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/verify/+server.ts create mode 100644 services/pp-auth-demo/src/routes/data/+page.server.ts create mode 100644 services/pp-auth-demo/src/routes/data/+page.svelte create mode 100644 services/pp-auth-demo/src/routes/login/+page.svelte create mode 100644 services/pp-auth-demo/src/routes/platforms/+page.server.ts create mode 100644 services/pp-auth-demo/src/routes/platforms/+page.svelte create mode 100644 services/pp-auth-demo/src/routes/terms/+page.server.ts create mode 100644 services/pp-auth-demo/src/routes/terms/+page.svelte create mode 100644 services/pp-auth-demo/src/svelte-qrcode.d.ts diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index f3d007484..c641039cf 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -4,41 +4,48 @@ sidebar_position: 9 # PP Auth demonstrator -A running demonstration of platform authentication and domain separation. Two platforms, one vault, and every attempt to reach data shown with the reason it succeeded or failed. +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**. Nothing else needs to be running — no database, no registry, no eVault. +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. -## What it shows +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. -**Chatterbox** is a social platform, certified L3 for `social` and `communication`. **Ledgerly** handles money, certified L4 for `finance`. Both are live, both will try to reach everything in the vault. +## Platforms -Point either one at a domain it was not certified for and it is refused — with a sentence saying so, not a status code. The refusal does not come from a list of platform names: it comes from the certificate the deployment presented, which does not name that domain and cannot be made to. +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. -**Your terms** sets the owner's side: the minimum level, whose reputation scores count and what score they must reach, and any domain refused outright. Signing produces a real signature over a real statement, which is verified before it takes effect. Raise the bar to L4 and Chatterbox stops being allowed anything; require a reputation of 50 and Ledgerly does, on the scores the demo's engine reports. +**Check it** verifies that deployment's chain of trust, from scratch, against records anyone can read: -**Try to cheat** is where the mechanism is visible. Each edit breaks exactly one link: - -| Edit | Fails at | +| Link | Where the evidence comes from | |---|---| -| Present a different public key — paste your own | Possession | -| Widen its own authorisation | Deployment authorised | -| Borrow the other platform's version document | Bundle integrity | -| Point at a different release | Version identity | -| Borrow the other platform's certificate | Accreditation | +| 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 chain trace re-runs on every attempt, so you can watch a link go red and read why. +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. -## What is real and what is not +## Your terms -The signatures are real — P-256 and ES256, verified by exactly the same code that verifies a live deployment. The tampering really does fail, for the reason shown. +The association says what a platform was found to be; you decide what that is worth. Set the minimum level, whose reputation scores you accept and the score they must reach, and any domain refused outright. -What is simulated is who holds the keys. The deployer's wallet, the registry and the association are stood in for by keys generated in the demo process, so it runs on its own. A chain that verifies here proves the mechanism works. It proves nothing about any particular platform, which is what the real roots are for. +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. -The minting facility lives at `@metastate-foundation/auth/platform/scenario`, deliberately behind a separate entry point so it cannot be reached by accident from code that verifies real deployments. +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 diff --git a/services/pp-auth-demo/package.json b/services/pp-auth-demo/package.json index 5459bc5b6..aaed2a76c 100644 --- a/services/pp-auth-demo/package.json +++ b/services/pp-auth-demo/package.json @@ -26,6 +26,10 @@ }, "dependencies": { "@metastate-foundation/auth": "workspace:*", - "jose": "^5.2.2" + "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.d.ts b/services/pp-auth-demo/src/app.d.ts index 4b0fb361a..219a939a2 100644 --- a/services/pp-auth-demo/src/app.d.ts +++ b/services/pp-auth-demo/src/app.d.ts @@ -1,7 +1,7 @@ declare global { namespace App { interface Locals { - /** The signed-in PPA admin, or null when unauthenticated. */ + /** The signed-in eVault owner, or null when unauthenticated. */ user: { ename: string } | null; } } 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/DeploymentPanel.svelte b/services/pp-auth-demo/src/lib/DeploymentPanel.svelte deleted file mode 100644 index 246b15ef0..000000000 --- a/services/pp-auth-demo/src/lib/DeploymentPanel.svelte +++ /dev/null @@ -1,218 +0,0 @@ - - -

-
-
-

{deployment.name}

-

{deployment.platformName}

-

{deployment.blurb}

-
-
- {#each deployment.certifiedDomains as domain (domain)} - {domain} - {/each} -
-
- - {#if deployment.tampered} -

- Evidence altered: {deployment.tampered}. Try reaching something and see - which step notices. -

- {/if} - -
-
Release
-
{deployment.releaseTag}
-
Put here by
-
{deployment.deployerEname}
-
Reputation
-
- {deployment.reputation === null ? "not scored" : deployment.reputation} -
-
- -
-

- Write something into the vault -

- -
- {#each domains as domain (domain.id)} - - {/each} -
-
- - {#if decision} -

- {decision.reason} -

- {:else if chain && !chain.ok && lastDomain} -

- {chain.links.find((link) => !link.ok)?.detail} -

- {/if} - -
-
-

- What it proved -

- -
- {#if chain} - - {:else} -

- Nothing checked yet. Reach for something above, or check now. -

- {/if} -
- -
- - Try to cheat - -
-
- - - -
-
- - - - - -
-
-
-
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..10c3d75b5 --- /dev/null +++ b/services/pp-auth-demo/src/lib/DeploymentRow.svelte @@ -0,0 +1,133 @@ + + +
+
+
+

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

+

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

+
+
+ {#if deployment.keyHeld} + Key supplied + {/if} + +
+
+ + {#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 — and only the deployment can show that. +

+ {#if showKey} + +
+ + +
+

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

+ {:else} + + {/if} +
+ {/if} + {/if} +
diff --git a/services/pp-auth-demo/src/lib/OwnerTerms.svelte b/services/pp-auth-demo/src/lib/TermsForm.svelte similarity index 50% rename from services/pp-auth-demo/src/lib/OwnerTerms.svelte rename to services/pp-auth-demo/src/lib/TermsForm.svelte index 28c480d4b..bac134e75 100644 --- a/services/pp-auth-demo/src/lib/OwnerTerms.svelte +++ b/services/pp-auth-demo/src/lib/TermsForm.svelte @@ -1,18 +1,14 @@
-
-

Your terms

-

What you will deal with

-

- The association says what a platform was found to be. You decide what - that is worth. Your answers are signed, so they travel with you and - anyone can check them — including the platform, before it bothers asking. -

-
-

The least you will accept @@ -100,7 +121,7 @@ class="mt-1" value={level.id} bind:group={minimumLevel} - onchange={() => (saved = false)} + onchange={() => (done = false)} /> {level.id} @@ -116,16 +137,8 @@ - (saved = false)} - placeholder={reputationEngine} - /> -

- Leave blank to ignore reputation entirely. -

+ (done = false)} /> +

Leave blank to ignore reputation entirely.

-
-

- Things nobody gets, whatever their certificate says -

-
- {#each domains as domain (domain.id)} - - {/each} + {#if domains.length > 0} +
+

+ Things nobody gets, whatever their certificate says +

+
+ {#each domains as domain (domain.id)} + + {/each} +
-
+ {/if} -
- - {#if saved} - Signed and in force. + {#if done} + Signed and published to your eVault. + {/if} + {#if error} + {error} {/if}
-
- - The statement you signed - -
{JSON.stringify(policy, null, 2)}
-

Signature: {signature.signature}

-
+ {#if uri} +
+

Approve these terms in your wallet.

+
+ +
+
+ {/if}
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/access.ts b/services/pp-auth-demo/src/lib/server/access.ts deleted file mode 100644 index 32865ec4e..000000000 --- a/services/pp-auth-demo/src/lib/server/access.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * One request through the whole mechanism: handshake, then the two gates. - * - * Every attempt runs the full handshake rather than caching a session. That is - * slower than a real deployment would be, and deliberate — the point of the - * demonstrator is that you can see the chain re-checked on each attempt and - * watch it fail the moment a link is broken. - */ - -import { - answerChallenge, - authorize, - verifyHandshake, - type AuthorizationDecision, - type ChainResult, -} from "@metastate-foundation/auth/platform"; -import { randomUUID } from "node:crypto"; -import { OWNER_ENAME, REPUTATION_ENGINE, type World } from "./world"; - -export interface AccessOutcome { - chain: ChainResult; - decision: AuthorizationDecision | null; - records: World["records"]; -} - -export async function attemptAccess( - current: World, - deploymentId: string, - domain: string, - write?: { kind: string; body: string }, -): Promise { - const deployment = current.deployments.get(deploymentId); - if (!deployment) throw new Error(`unknown deployment ${deploymentId}`); - - const challenge = current.challenges.issue(OWNER_ENAME); - const response = await answerChallenge(deployment.identity, challenge); - const chain = await verifyHandshake(response, { - audience: OWNER_ENAME, - registryBaseUrl: "demo://registry", - registryJwksUri: "demo://registry/.well-known/jwks.json", - verifyWalletSignature: current.roots.verifyWalletSignature, - resolveJwks: current.resolveJwks, - store: current.challenges, - }); - - if (!chain.ok || !chain.claim) { - record(current, deployment.id, deployment.identity.evidence.deploymentName, domain, { - allowed: false, - code: "handshake-failed", - reason: `Could not establish who this is: ${chain.links.find((link) => !link.ok)?.detail ?? "the chain of trust did not hold"}`, - }); - return { chain, decision: null, records: [] }; - } - - const score = current.reputation.get(chain.claim.platformEname); - const decision = authorize(current.policy.statement, { - claim: chain.claim, - domain, - reputation: - score === undefined ? null : { engine: REPUTATION_ENGINE, score }, - }); - record( - current, - deployment.id, - chain.claim.platformName, - domain, - decision, - ); - - if (!decision.allowed) return { chain, decision, records: [] }; - - if (write) { - current.records.unshift({ - id: randomUUID(), - domain, - kind: write.kind, - body: write.body, - writtenBy: chain.claim.platformName, - at: new Date().toISOString(), - }); - } - - return { - chain, - decision, - records: current.records.filter((entry) => entry.domain === domain), - }; -} - -function record( - current: World, - deploymentId: string, - deploymentName: string, - domain: string, - decision: { allowed: boolean; reason: string; code: string }, -): void { - current.attempts.unshift({ - id: randomUUID(), - at: new Date().toISOString(), - deploymentId, - deploymentName, - domain, - allowed: decision.allowed, - reason: decision.reason, - code: decision.code, - }); - current.attempts.splice(50); -} 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..39990647a --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/data.ts @@ -0,0 +1,87 @@ +/** + * 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 } 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, without guessing at its shape. */ +function summarise(parsed: Record): string { + const preferred = [ + "text", "content", "body", "message", "title", "name", + "displayName", "description", "summary", "label", + ]; + for (const key of preferred) { + const value = parsed[key]; + if (typeof value === "string" && value.trim()) { + return value.trim().slice(0, 160); + } + } + const first = Object.entries(parsed).find( + ([, value]) => typeof value === "string" && value.trim().length > 0, + ); + return first ? `${first[0]}: ${String(first[1]).slice(0, 140)}` : "(no readable fields)"; +} + +/** + * 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); +} 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..8939ac78f --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/env.ts @@ -0,0 +1,53 @@ +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"; +} + +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/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..9f0a5ff78 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/ontology.ts @@ -0,0 +1,35 @@ +/** 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 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..d44d02658 --- /dev/null +++ b/services/pp-auth-demo/src/lib/server/session.ts @@ -0,0 +1,119 @@ +/** + * 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); + } +} + +export function createLoginOffer(): { uri: string; session: string } { + sweep(); + const session = randomUUID(); + sessions.set(session, { createdAt: Date.now(), kind: "login", status: "pending" }); + const redirect = new URL("/api/auth", publicUrl()).toString(); + 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, +): { uri: string; session: string } { + sweep(); + sessions.set(payload, { createdAt: Date.now(), kind: "policy", status: "pending" }); + const redirect = new URL("/api/sign", publicUrl()).toString(); + 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/lib/server/world.ts b/services/pp-auth-demo/src/lib/server/world.ts deleted file mode 100644 index 6a2ea7cb8..000000000 --- a/services/pp-auth-demo/src/lib/server/world.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * The demonstrator's world: two deployments, one vault, one owner. - * - * Everything here is genuine cryptography checked by the real PP Auth - * verifier. What is simulated is the *setting*, not the mechanism: the keys - * standing in for the deployer's wallet, the registry and the association are - * generated in this process rather than held by those parties, so the - * demonstrator can run on a laptop with nothing else switched on. - * - * State is anchored on globalThis because Vite's dev server evaluates a module - * once per import graph, and a world that exists twice would issue challenges - * from one copy and try to redeem them in the other. - */ - -import { - accessPolicyPayload, - createChallengeStore, - defaultAccessPolicy, - generateKeyPair, - signP256, - verifyP256, - type AccessPolicyStatement, - type ChallengeStore, - type DeploymentIdentity, - type SignedAccessPolicy, -} from "@metastate-foundation/auth/platform"; -// Minting evidence from local keys is a demonstration and test facility. It is -// deliberately a separate entry point so it can never be reached by accident -// from code that verifies real deployments. -import { - createTrustRoots, - mintDeployment, - type TrustRoots, -} from "@metastate-foundation/auth/platform/scenario"; -import { createLocalJWKSet } from "jose"; - -/** The person whose eVault the demonstration is about. */ -export const OWNER_ENAME = "@849c0221-6f3f-55f9-95f0-f3b0d2b3092f"; - -export const ISSUER_JWKS_URI = "demo://association/.well-known/jwks.json"; -export const REGISTRY_JWKS_URI = "demo://registry/.well-known/jwks.json"; -export const REPUTATION_ENGINE = "@ereputation.w3ds"; - -export interface VaultRecord { - id: string; - domain: string; - kind: string; - body: string; - writtenBy: string; - at: string; -} - -export interface AttemptLog { - id: string; - at: string; - deploymentId: string; - deploymentName: string; - domain: string; - allowed: boolean; - reason: string; - code: string; -} - -export interface DemoDeployment { - id: string; - /** How the platform describes itself, for the card. */ - blurb: string; - identity: DeploymentIdentity; - /** Set when the operator has tampered with the evidence, so the UI can say so. */ - tampered: string | null; - pristine: DeploymentIdentity; -} - -export interface World { - roots: TrustRoots; - challenges: ChallengeStore; - deployments: Map; - records: VaultRecord[]; - attempts: AttemptLog[]; - policy: SignedAccessPolicy; - ownerKey: { publicKey: string; privateKey: string }; - /** Reputation the demo's engine reports, keyed by platform eName. */ - reputation: Map; - resolveJwks: (uri: string) => ReturnType; -} - -const KEY = Symbol.for("pp-auth-demo.world"); -const store = globalThis as typeof globalThis & { [KEY]?: Promise }; - -const CHATTERBOX = "@11111111-1111-4111-8111-111111111111"; -const LEDGERLY = "@22222222-2222-4222-8222-222222222222"; - -export async function signPolicy( - statement: AccessPolicyStatement, - ownerKey: { privateKey: string }, -): Promise { - const payload = accessPolicyPayload(statement); - return { - statement, - payload, - signature: await signP256(ownerKey.privateKey, payload), - signer: statement.subject, - }; -} - -async function build(): Promise { - const deployerEname = "@d0000000-0000-4000-8000-00000000dep1"; - const roots = await createTrustRoots(deployerEname); - const registryKeys = createLocalJWKSet(roots.registry.jwks); - const associationKeys = createLocalJWKSet(roots.association.jwks); - - const shared = { - issuerJwksUri: ISSUER_JWKS_URI, - registryJwksUri: REGISTRY_JWKS_URI, - reviewedByEName: OWNER_ENAME, - }; - - const [chatterbox, ledgerly] = await Promise.all([ - mintDeployment(roots, { - ...shared, - platformEname: CHATTERBOX, - platformName: "Chatterbox", - deploymentName: "chatterbox-eu", - environment: "production", - version: "1.4.0", - releaseTag: "v1.4.0", - commitSha: "4f9c1a77b2e3d5086a1c9f2b3d4e5f60718293a4", - repository: "https://gitw3.example/acme/chatterbox", - requestedDomains: ["social", "communication"], - level: "L3", - }), - mintDeployment(roots, { - ...shared, - platformEname: LEDGERLY, - platformName: "Ledgerly", - deploymentName: "ledgerly-eu", - environment: "production", - version: "2.1.3", - releaseTag: "v2.1.3", - commitSha: "9b1e2d3c4a5f60718293a4b5c6d7e8f90a1b2c3d", - repository: "https://gitw3.example/acme/ledgerly", - requestedDomains: ["finance"], - level: "L4", - }), - ]); - - const deployments = new Map([ - [ - "chatterbox", - { - id: "chatterbox", - blurb: "A social platform. Posts, feeds, and direct messages.", - identity: chatterbox.identity, - pristine: structuredClone(chatterbox.identity), - tampered: null, - }, - ], - [ - "ledgerly", - { - id: "ledgerly", - blurb: "A finance platform. Accounts, ledgers, and payments.", - identity: ledgerly.identity, - pristine: structuredClone(ledgerly.identity), - tampered: null, - }, - ], - ]); - - const ownerKey = await generateKeyPair(); - const policy = await signPolicy( - { - ...defaultAccessPolicy(OWNER_ENAME), - minimumLevel: "L2", - issuedAt: new Date().toISOString(), - nonce: "initial", - }, - ownerKey, - ); - - return { - roots, - challenges: createChallengeStore(), - deployments, - records: seedRecords(), - attempts: [], - policy, - ownerKey, - reputation: new Map([ - [CHATTERBOX, 62], - [LEDGERLY, 31], - ]), - resolveJwks: (uri) => - uri === ISSUER_JWKS_URI ? associationKeys : registryKeys, - }; -} - -function seedRecords(): VaultRecord[] { - const at = new Date().toISOString(); - return [ - { - id: "seed-social", - domain: "social", - kind: "Social media post", - body: "Spent the morning on the allotment. Tomatoes finally coming through.", - writtenBy: OWNER_ENAME, - at, - }, - { - id: "seed-finance", - domain: "finance", - kind: "Account", - body: "Current account · balance £4,182.60 · sort 04-00-04", - writtenBy: OWNER_ENAME, - at, - }, - { - id: "seed-comms", - domain: "communication", - kind: "Message", - body: "Are we still on for Thursday?", - writtenBy: OWNER_ENAME, - at, - }, - ]; -} - -export function world(): Promise { - if (!store[KEY]) store[KEY] = build(); - return store[KEY]; -} - -/** Verifies a signature made by the demo owner's stand-in wallet. */ -export async function verifyOwnerSignature( - current: World, - signature: string, - payload: string, -): Promise { - return verifyP256(current.ownerKey.publicKey, signature, payload); -} - -export async function resetWorld(): Promise { - store[KEY] = build(); - return store[KEY]; -} 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 index ff9e9ab80..cb77319f9 100644 --- a/services/pp-auth-demo/src/routes/+layout.svelte +++ b/services/pp-auth-demo/src/routes/+layout.svelte @@ -1,28 +1,52 @@
-
-
+
+

Post Platforms Association

- How a platform proves what it is + What each platform can reach, and why

- - Documentation - + {#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 index 104244342..725ee09e0 100644 --- a/services/pp-auth-demo/src/routes/+page.server.ts +++ b/services/pp-auth-demo/src/routes/+page.server.ts @@ -1,44 +1,6 @@ -import { DOMAINS } from "$lib/domains"; -import { OWNER_ENAME, REPUTATION_ENGINE, world } from "$lib/server/world"; +import { redirect } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async () => { - const current = await world(); - - const deployments = [...current.deployments.values()].map((deployment) => { - const evidence = deployment.identity.evidence; - return { - id: deployment.id, - blurb: deployment.blurb, - name: evidence.deploymentName, - platformName: evidence.submissionProof.statement.platformName, - platformEname: evidence.platformEname, - deploymentEname: evidence.deploymentEname, - environment: evidence.environment, - version: evidence.version, - releaseTag: evidence.releaseTag, - commitSha: evidence.commitSha, - publicKey: evidence.publicKey, - deployerEname: evidence.deployerEname, - certifiedDomains: evidence.submissionProof.statement.domains, - repository: evidence.submissionProof.statement.repository, - reputation: current.reputation.get(evidence.platformEname) ?? null, - tampered: deployment.tampered, - }; - }); - - return { - owner: OWNER_ENAME, - reputationEngine: REPUTATION_ENGINE, - domains: DOMAINS, - deployments, - policy: current.policy.statement, - policySignature: { - payload: current.policy.payload, - signature: current.policy.signature, - signer: current.policy.signer, - }, - records: current.records, - attempts: current.attempts, - }; + throw redirect(302, "/platforms"); }; diff --git a/services/pp-auth-demo/src/routes/+page.svelte b/services/pp-auth-demo/src/routes/+page.svelte deleted file mode 100644 index c9a3a1284..000000000 --- a/services/pp-auth-demo/src/routes/+page.svelte +++ /dev/null @@ -1,127 +0,0 @@ - - -
-
-

The demonstration

-

- Two platforms want your data. Only one of them should get each thing. -

-

- Chatterbox is a social platform. Ledgerly handles money. Both are - running, both are certified, and both will now try to reach everything - in your vault — your posts, your messages, your accounts, your health - records. Watch what each one is actually allowed to touch, and why. -

-

- Nothing here is enforced by a list of platform names. Each one proves, - from scratch on every attempt, which release it is running and what - that release was certified for. A social platform has no way to say - the word “finance”: it is not in its certificate, and nothing it can - present will put it there. -

- -
- -
- {#each data.deployments as deployment, index (deployment.id)} - - {/each} -
- - {#key data.policy.nonce} - - {/key} - -
-
-
-

Your vault

-

What is in there

-
-
    - {#each data.records as record (record.id)} -
  • -
    - {record.domain} - - {record.writtenBy === data.owner ? "you" : record.writtenBy} - -
    -

    {record.body}

    -
  • - {/each} -
-
- -
-
-

What happened

-

Every attempt, kept

-
- {#if data.attempts.length === 0} -

- Nothing has tried to reach your data yet. -

- {:else} -
    - {#each data.attempts as attempt (attempt.id)} -
  • - -
    -

    {attempt.reason}

    -

    - {new Date(attempt.at).toLocaleTimeString()} -

    -
    -
  • - {/each} -
- {/if} -
-
- -
-

Honesty note

-

- The signatures on this page are real and are checked by the same code - that checks a live deployment. What is simulated is who holds the - keys: the wallet, the registry and the association are stood in for by - keys generated in this process, so the demonstration runs on its own. - A chain that verifies here proves the mechanism works — not that any - particular platform is trustworthy. -

-
-
diff --git a/services/pp-auth-demo/src/routes/api/access/+server.ts b/services/pp-auth-demo/src/routes/api/access/+server.ts deleted file mode 100644 index 850e5575c..000000000 --- a/services/pp-auth-demo/src/routes/api/access/+server.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { attemptAccess } from "$lib/server/access"; -import { world } from "$lib/server/world"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async ({ request }) => { - const body = (await request.json()) as { - deploymentId?: string; - domain?: string; - kind?: string; - text?: string; - }; - const current = await world(); - const text = (body.text ?? "").trim(); - - try { - const outcome = await attemptAccess( - current, - String(body.deploymentId), - String(body.domain), - text ? { kind: body.kind || "Note", body: text } : undefined, - ); - return json(outcome); - } catch (error) { - return json( - { error: error instanceof Error ? error.message : "failed" }, - { status: 400 }, - ); - } -}; 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..84d3fb8f1 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts @@ -0,0 +1,5 @@ +import { json } from "@sveltejs/kit"; +import { createLoginOffer } from "$lib/server/session"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async () => json(createLoginOffer()); 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/handshake/+server.ts b/services/pp-auth-demo/src/routes/api/handshake/+server.ts deleted file mode 100644 index a91e0ba1a..000000000 --- a/services/pp-auth-demo/src/routes/api/handshake/+server.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { - answerChallenge, - verifyHandshake, -} from "@metastate-foundation/auth/platform"; -import { OWNER_ENAME, world } from "$lib/server/world"; -import type { RequestHandler } from "./$types"; - -/** Runs the handshake alone, so the chain can be inspected without touching data. */ -export const POST: RequestHandler = async ({ request }) => { - const { deploymentId } = (await request.json()) as { deploymentId?: string }; - const current = await world(); - const deployment = current.deployments.get(String(deploymentId)); - if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 }); - - const challenge = current.challenges.issue(OWNER_ENAME); - const response = await answerChallenge(deployment.identity, challenge); - const chain = await verifyHandshake(response, { - audience: OWNER_ENAME, - registryBaseUrl: "demo://registry", - registryJwksUri: "demo://registry/.well-known/jwks.json", - verifyWalletSignature: current.roots.verifyWalletSignature, - resolveJwks: current.resolveJwks, - store: current.challenges, - }); - - return json({ chain, challenge }); -}; 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/policy/+server.ts b/services/pp-auth-demo/src/routes/api/policy/+server.ts deleted file mode 100644 index 4104061d0..000000000 --- a/services/pp-auth-demo/src/routes/api/policy/+server.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { - CERTIFICATION_LEVELS, - defaultAccessPolicy, - verifyAccessPolicy, - type CertificationLevel, -} from "@metastate-foundation/auth/platform"; -import { OWNER_ENAME, signPolicy, verifyOwnerSignature, world } from "$lib/server/world"; -import type { RequestHandler } from "./$types"; - -/** - * Records the owner's terms as a signed statement. - * - * The signature is made here with the owner's stand-in key; in a running - * system this is where the eID wallet signs. It is verified immediately after - * signing, so a statement that could not be checked never becomes the policy. - */ -export const POST: RequestHandler = async ({ request }) => { - const body = (await request.json()) as Record; - const current = await world(); - - const level = String(body.minimumLevel ?? "") as CertificationLevel; - if (!CERTIFICATION_LEVELS.includes(level)) { - return json({ error: "Unknown level" }, { status: 400 }); - } - const strings = (value: unknown): string[] => - Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; - const minimumReputation = - body.minimumReputation === null || body.minimumReputation === "" - ? null - : Number(body.minimumReputation); - if (minimumReputation !== null && !Number.isFinite(minimumReputation)) { - return json({ error: "Reputation threshold must be a number" }, { status: 400 }); - } - - const statement = { - ...defaultAccessPolicy(OWNER_ENAME), - minimumLevel: level, - reputationEngine: - typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "", - minimumReputation, - allowedDomains: body.allowedDomains === null ? null : strings(body.allowedDomains), - deniedDomains: strings(body.deniedDomains), - issuedAt: new Date().toISOString(), - nonce: crypto.randomUUID(), - }; - - const signed = await signPolicy(statement, current.ownerKey); - const valid = await verifyAccessPolicy(signed, (_signer, signature, payload) => - verifyOwnerSignature(current, signature, payload), - ); - if (!valid) { - return json({ error: "The signed terms did not verify" }, { status: 500 }); - } - - current.policy = signed; - return json({ policy: signed.statement, payload: signed.payload, signature: signed.signature }); -}; diff --git a/services/pp-auth-demo/src/routes/api/reset/+server.ts b/services/pp-auth-demo/src/routes/api/reset/+server.ts deleted file mode 100644 index a91debee3..000000000 --- a/services/pp-auth-demo/src/routes/api/reset/+server.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { resetWorld } from "$lib/server/world"; -import type { RequestHandler } from "./$types"; - -export const POST: RequestHandler = async () => { - await resetWorld(); - return json({ ok: true }); -}; diff --git a/services/pp-auth-demo/src/routes/api/tamper/+server.ts b/services/pp-auth-demo/src/routes/api/tamper/+server.ts deleted file mode 100644 index d437b9a44..000000000 --- a/services/pp-auth-demo/src/routes/api/tamper/+server.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { json } from "@sveltejs/kit"; -import { resetWorld, world } from "$lib/server/world"; -import type { RequestHandler } from "./$types"; - -/** - * Breaks one link on purpose. - * - * A chain that only ever passes demonstrates nothing. Each of these edits is - * something an attacker would plausibly try — presenting a key you do not - * hold, widening your own authorisation, borrowing a better-certified - * release's paperwork — and each should be caught by exactly one link. - */ -const EDITS: Record< - string, - { label: string; expect: string; apply: (deployment: any, other: any, value: string) => void } -> = { - publicKey: { - label: "Present a different public key", - expect: "possession", - apply: (deployment, _other, value) => { - deployment.identity.evidence.publicKey = value; - deployment.identity.evidence.deploymentKeyDocument.data.publicKey = value; - }, - }, - environment: { - label: "Promote itself from staging to production", - expect: "deployment-authorised", - apply: (deployment, _other, value) => { - deployment.identity.evidence.deploymentKeyDocument.data.environment = - value || "production-plus"; - }, - }, - versionDocument: { - label: "Borrow the other platform's version document", - expect: "bundle-integrity", - apply: (deployment, other) => { - deployment.identity.evidence.softwareVersionDocument = - structuredClone(other.identity.evidence.softwareVersionDocument); - }, - }, - versionEname: { - label: "Point at a different release", - expect: "version-identity", - apply: (deployment, _other, value) => { - deployment.identity.evidence.versionEname = - value || "@99999999-9999-4999-8999-999999999999"; - }, - }, - certificate: { - label: "Borrow the other platform's certificate", - expect: "accreditation", - apply: (deployment, other) => { - deployment.identity.evidence.accreditationJws = - other.identity.evidence.accreditationJws; - }, - }, -}; - -export const POST: RequestHandler = async ({ request }) => { - const { deploymentId, edit, value } = (await request.json()) as { - deploymentId?: string; - edit?: string; - value?: string; - }; - const current = await world(); - const deployment = current.deployments.get(String(deploymentId)); - if (!deployment) return json({ error: "Unknown deployment" }, { status: 404 }); - - if (edit === "restore") { - deployment.identity = structuredClone(deployment.pristine); - deployment.tampered = null; - return json({ tampered: null }); - } - - const change = EDITS[String(edit)]; - if (!change) return json({ error: "Unknown edit" }, { status: 400 }); - - const other = [...current.deployments.values()].find( - (entry) => entry.id !== deployment.id, - ); - change.apply(deployment, other, String(value ?? "")); - deployment.tampered = change.label; - - return json({ tampered: change.label, expect: change.expect }); -}; - -export const DELETE: RequestHandler = async () => { - await resetWorld(); - 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..a54ac1e30 --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts @@ -0,0 +1,59 @@ +import { json } from "@sveltejs/kit"; +import { + CERTIFICATION_LEVELS, + defaultAccessPolicy, + type CertificationLevel, +} from "@metastate-foundation/auth/platform"; +import { randomUUID } from "node:crypto"; +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 }) => { + 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 minimumReputation = + body.minimumReputation === null || body.minimumReputation === "" + ? null + : Number(body.minimumReputation); + if (minimumReputation !== null && !Number.isFinite(minimumReputation)) { + return json({ error: "The score must be a number" }, { status: 400 }); + } + + const statement = { + ...defaultAccessPolicy(ename), + minimumLevel: level, + reputationEngine: + typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "", + minimumReputation, + 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, + reputationEngine: statement.reputationEngine || "not used", + minimumReputation: statement.minimumReputation ?? "no threshold", + refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing", + }); + + 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..c8778b3ce --- /dev/null +++ b/services/pp-auth-demo/src/routes/terms/+page.server.ts @@ -0,0 +1,12 @@ +import { listDomains } from "$lib/server/domains"; +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 }; +}; 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..7c6a9e39e --- /dev/null +++ b/services/pp-auth-demo/src/routes/terms/+page.svelte @@ -0,0 +1,49 @@ + + +
+
+

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; +} From 68b23f91c9830c8fec2f66dd2ba8ad24cfa531d7 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:15:28 +0800 Subject: [PATCH 19/29] fix: point wallet callbacks at the address the page was served on The QR encoded localhost, so a phone scanning it called back to itself and the login never completed. Also adds the w3ds://sign callback route, which was missing entirely. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demo/src/lib/server/session.ts | 20 +++++++-- .../src/routes/api/auth/offer/+server.ts | 3 +- .../src/routes/api/sign/+server.ts | 41 +++++++++++++++++++ .../src/routes/api/terms/+server.ts | 20 +++++---- 4 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 services/pp-auth-demo/src/routes/api/sign/+server.ts diff --git a/services/pp-auth-demo/src/lib/server/session.ts b/services/pp-auth-demo/src/lib/server/session.ts index d44d02658..6ba2fba00 100644 --- a/services/pp-auth-demo/src/lib/server/session.ts +++ b/services/pp-auth-demo/src/lib/server/session.ts @@ -43,11 +43,24 @@ function sweep(): void { } } -export function createLoginOffer(): { uri: string; session: string } { +/** + * 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 = new URL("/api/auth", publicUrl()).toString(); + const redirect = callback(origin, "/api/auth"); return { session, uri: `w3ds://auth?redirect=${redirect}&session=${session}&platform=pp-auth-demo`, @@ -62,10 +75,11 @@ export function createLoginOffer(): { uri: string; session: string } { 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 = new URL("/api/sign", publicUrl()).toString(); + const redirect = callback(origin, "/api/sign"); const data = Buffer.from(JSON.stringify(summary), "utf8").toString("base64"); return { session: payload, 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 index 84d3fb8f1..cc3ed86b1 100644 --- a/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts +++ b/services/pp-auth-demo/src/routes/api/auth/offer/+server.ts @@ -2,4 +2,5 @@ import { json } from "@sveltejs/kit"; import { createLoginOffer } from "$lib/server/session"; import type { RequestHandler } from "./$types"; -export const POST: RequestHandler = async () => json(createLoginOffer()); +export const POST: RequestHandler = async ({ url }) => + json(createLoginOffer(url.origin)); 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 index a54ac1e30..47a1bfcc7 100644 --- a/services/pp-auth-demo/src/routes/api/terms/+server.ts +++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts @@ -16,7 +16,7 @@ import type { RequestHandler } from "./$types"; * 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 }) => { +export const POST: RequestHandler = async ({ request, locals, url }) => { const body = (await request.json()) as Record; const ename = locals.user!.ename; @@ -47,13 +47,17 @@ export const POST: RequestHandler = async ({ request, locals }) => { }; const prepared = prepare(statement); - const offer = createSigningOffer(prepared.payload, { - message: "Set the terms platforms must meet to reach your data", - minimumLevel: statement.minimumLevel, - reputationEngine: statement.reputationEngine || "not used", - minimumReputation: statement.minimumReputation ?? "no threshold", - refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing", - }); + const offer = createSigningOffer( + prepared.payload, + { + message: "Set the terms platforms must meet to reach your data", + minimumLevel: statement.minimumLevel, + reputationEngine: statement.reputationEngine || "not used", + minimumReputation: statement.minimumReputation ?? "no threshold", + refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing", + }, + url.origin, + ); return json({ statement, payload: prepared.payload, uri: offer.uri }); }; From ac17d50598c01ac63a598b1ac10ee0e677c419ac Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:30:26 +0800 Subject: [PATCH 20/29] feat: gate requests on what a platform may do, not just what it is Certification says which data a release was assessed for; it says nothing about operations. Adds a third gate over the existing AccessGrant ontology: resource:Action permissions, deny by default, append-only revisions, with a withdrawn grant distinguishable from one never made. Deployment keys move to the top of the flow rather than appearing after a failed check, since possession decides what a check can mean. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 10 + docs/docs/W3DS Basics/Access-Policy.md | 16 ++ packages/auth/src/platform/authorize.ts | 76 ++++++- packages/auth/src/platform/grants.spec.ts | 190 ++++++++++++++++++ packages/auth/src/platform/grants.ts | 110 ++++++++++ packages/auth/src/platform/index.ts | 2 + .../pp-auth-demo/src/lib/DeploymentRow.svelte | 67 ++---- services/pp-auth-demo/src/lib/KeyEntry.svelte | 94 +++++++++ .../pp-auth-demo/src/lib/RequestTester.svelte | 130 ++++++++++++ .../pp-auth-demo/src/lib/server/grants.ts | 129 ++++++++++++ .../pp-auth-demo/src/lib/server/ontology.ts | 1 + .../pp-auth-demo/src/routes/+layout.svelte | 1 + .../src/routes/acl/+page.server.ts | 70 +++++++ .../pp-auth-demo/src/routes/acl/+page.svelte | 150 ++++++++++++++ .../src/routes/api/grants/+server.ts | 38 ++++ .../src/routes/api/request/+server.ts | 78 +++++++ 16 files changed, 1100 insertions(+), 62 deletions(-) create mode 100644 packages/auth/src/platform/grants.spec.ts create mode 100644 packages/auth/src/platform/grants.ts create mode 100644 services/pp-auth-demo/src/lib/KeyEntry.svelte create mode 100644 services/pp-auth-demo/src/lib/RequestTester.svelte create mode 100644 services/pp-auth-demo/src/lib/server/grants.ts create mode 100644 services/pp-auth-demo/src/routes/acl/+page.server.ts create mode 100644 services/pp-auth-demo/src/routes/acl/+page.svelte create mode 100644 services/pp-auth-demo/src/routes/api/grants/+server.ts create mode 100644 services/pp-auth-demo/src/routes/api/request/+server.ts diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index c641039cf..036808d18 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -39,6 +39,16 @@ Your own eVault records, grouped by the domain each schema declares. That groupi 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: for each certified platform, a read and a write toggle per domain it was certified for. Nothing else is listed, because anything else is refused before permissions are consulted. + +Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both toggles withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given. + +**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. 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, whose reputation scores you accept and the score they must reach, and any domain refused outright. diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md index 62cddef5d..70db38fd1 100644 --- a/docs/docs/W3DS Basics/Access-Policy.md +++ b/docs/docs/W3DS Basics/Access-Policy.md @@ -47,6 +47,22 @@ The newest statement for a subject is the one in force. An owner who has never s 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/packages/auth/src/platform/authorize.ts b/packages/auth/src/platform/authorize.ts index 50bff74e8..467613868 100644 --- a/packages/auth/src/platform/authorize.ts +++ b/packages/auth/src/platform/authorize.ts @@ -1,16 +1,29 @@ /** * What a verified platform may actually touch. * - * Two independent gates, and both must open. The association's certificate - * names the domains a release was assessed for; the owner's policy names what - * they will permit. 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. + * 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" @@ -18,7 +31,10 @@ export type DenialCode = | "domain-outside-owner-allowlist" | "level-below-policy" | "reputation-engine-not-accepted" - | "reputation-below-policy"; + | "reputation-below-policy" + | "operation-not-granted" + | "grant-revoked" + | "grant-expired"; export interface ReputationReading { /** eName or URL of the engine that produced it. */ @@ -31,6 +47,16 @@ export interface AuthorizationRequest { /** 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 { @@ -97,9 +123,40 @@ export function authorize( } } + 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 meets your terms.`, + reason: `${claim.platformName} is certified ${claim.level} for ${domain} data and may ${did} it.`, code: "granted", }; } @@ -115,4 +172,7 @@ export function permittedDomains( ); } +export { permissionFor }; +export type { AccessGrant, Operation }; + export type { CertificationLevel }; 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/index.ts b/packages/auth/src/platform/index.ts index a243f27cf..9cfffd134 100644 --- a/packages/auth/src/platform/index.ts +++ b/packages/auth/src/platform/index.ts @@ -30,6 +30,8 @@ export { } 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, diff --git a/services/pp-auth-demo/src/lib/DeploymentRow.svelte b/services/pp-auth-demo/src/lib/DeploymentRow.svelte index 10c3d75b5..0fa5c2710 100644 --- a/services/pp-auth-demo/src/lib/DeploymentRow.svelte +++ b/services/pp-auth-demo/src/lib/DeploymentRow.svelte @@ -1,5 +1,6 @@
@@ -73,14 +56,13 @@ {deployment.releaseTag} · {deployment.commitSha.slice(0, 12)}

-
- {#if deployment.keyHeld} - Key supplied - {/if} - -
+ +
+ +
+
{#if checked && missing.length > 0} @@ -100,34 +82,11 @@ {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 — and only the deployment can show that. -

- {#if showKey} - -
- - -
-

- Kept in memory for this process only. Never written down, never logged. -

- {:else} - - {/if} -
+

+ 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..ec3951f28 --- /dev/null +++ b/services/pp-auth-demo/src/lib/RequestTester.svelte @@ -0,0 +1,130 @@ + + +
+

Try a request

+ +
+ + + +
+ + {#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 chain} +
+ + What it proved + +
+ +
+
+ {/if} +
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/ontology.ts b/services/pp-auth-demo/src/lib/server/ontology.ts index 9f0a5ff78..44180ff91 100644 --- a/services/pp-auth-demo/src/lib/server/ontology.ts +++ b/services/pp-auth-demo/src/lib/server/ontology.ts @@ -4,6 +4,7 @@ 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; diff --git a/services/pp-auth-demo/src/routes/+layout.svelte b/services/pp-auth-demo/src/routes/+layout.svelte index cb77319f9..20027148a 100644 --- a/services/pp-auth-demo/src/routes/+layout.svelte +++ b/services/pp-auth-demo/src/routes/+layout.svelte @@ -7,6 +7,7 @@ const TABS = [ { href: "/platforms", label: "Platforms" }, { href: "/data", label: "Your data" }, + { href: "/acl", label: "Permissions" }, { href: "/terms", label: "Your terms" }, ]; 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..e789b6c78 --- /dev/null +++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts @@ -0,0 +1,70 @@ +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 certified + * platforms, the domains they were certified for, and what each has actually + * been permitted to do. + */ +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: (record.domains ?? []).map((domain) => { + const grant = grants.find( + (entry) => + entry.granteeEName === record.platformEName && + entry.resourceType === domain, + ); + const active = grant && grant.status === "active"; + return { + domain, + label: domains.find((d) => d.id === domain)?.label ?? 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..3c45d0cc9 --- /dev/null +++ b/services/pp-auth-demo/src/routes/acl/+page.svelte @@ -0,0 +1,150 @@ + + +
+
+

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, and this is + where that is decided. Each change is kept in your own eVault as a + permission record, so nothing is lost when you take access away. +

+
+ + {#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} +

+
+
+ + {#if platform.deployments.length > 0} +
+

+ Deployment keys +

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

+ Permissions +

+
+ + + + + + + + + + + {#each platform.grants as grant (grant.domain)} + + + + + + + {/each} + +
DataReadWriteState
{grant.label} + toggle(platform.ename, grant, "read")} + /> + + toggle(platform.ename, grant, "write")} + /> + + {#if grant.read || grant.write} + permitted + {:else if grant.revoked} + withdrawn + {:else} + never given + {/if} +
+
+

+ Only the data this platform was certified for is listed. Anything + else is refused before permissions are even consulted. +

+
+ + {#if platform.deployments.length > 0} + ({ + domain: grant.domain, + label: grant.label, + }))} + /> + {/if} +
+ {/each} +
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/request/+server.ts b/services/pp-auth-demo/src/routes/api/request/+server.ts new file mode 100644 index 000000000..41559ba6c --- /dev/null +++ b/services/pp-auth-demo/src/routes/api/request/+server.ts @@ -0,0 +1,78 @@ +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 { 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; + }; + 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, + }); + + return json({ stage: "authorised", chain, decision, missing: [] }); +}; + +export const GET: RequestHandler = async () => + json({ error: "POST a deployment, domain and operation" }, { status: 405 }); From 55cb212698c6d1c62ec1964e73d7dc361c1f58de Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:38:28 +0800 Subject: [PATCH 21/29] feat: return real records from a permitted read A request that answered allow or deny without touching the eVault proved nothing. A permitted read now fetches and renders the records; a refused one fetches nothing and says the vault was never asked; a permitted write really writes and reads the domain back. Also summarises balances, amounts and file sizes, which have no prose field and were rendering as unreadable. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 6 +- .../pp-auth-demo/src/lib/RequestTester.svelte | 52 ++++++++- services/pp-auth-demo/src/lib/server/data.ts | 108 +++++++++++++++++- .../src/routes/api/request/+server.ts | 40 ++++++- 4 files changed, 197 insertions(+), 9 deletions(-) diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index 036808d18..ea4827269 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -47,7 +47,11 @@ Each change writes an `AccessGrant` into your own eVault as a new revision. Clea **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. 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". +**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 diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte index ec3951f28..2cd687ce8 100644 --- a/services/pp-auth-demo/src/lib/RequestTester.svelte +++ b/services/pp-auth-demo/src/lib/RequestTester.svelte @@ -32,6 +32,10 @@ let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null); let stage = $state(null); let missing = $state([]); + let records = $state | null>(null); + let wrote = $state<{ id: string; kind: string } | null>(null); + let note = $state(null); + let text = $state(""); async function send() { busy = true; @@ -39,13 +43,17 @@ const res = await fetch("/api/request", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deploymentEname, domain, operation }), + body: JSON.stringify({ deploymentEname, domain, operation, text }), }); const body = await res.json(); chain = body.chain ?? null; decision = body.decision ?? null; stage = body.stage ?? null; missing = body.missing ?? []; + records = body.records ?? null; + wrote = body.wrote ?? null; + note = body.note ?? null; + if (body.wrote) text = ""; } finally { busy = false; } @@ -85,6 +93,13 @@ + {#if operation === "write"} + + {/if} + {#if !held}

This deployment has no key here, so it cannot prove who it is and the @@ -117,6 +132,41 @@

{/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}
diff --git a/services/pp-auth-demo/src/lib/server/data.ts b/services/pp-auth-demo/src/lib/server/data.ts index 39990647a..3c392725d 100644 --- a/services/pp-auth-demo/src/lib/server/data.ts +++ b/services/pp-auth-demo/src/lib/server/data.ts @@ -5,7 +5,7 @@ * ours: this is exactly the partition a certificate grants against. */ -import { envelopes } from "./evault"; +import { envelopes, store_ } from "./evault"; import { listDomains, listSchemas } from "./domains"; export interface OwnedRecord { @@ -22,22 +22,61 @@ export interface DomainGroup { records: OwnedRecord[]; } -/** A short readable line for a record, without guessing at its shape. */ +/** + * 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 preferred = [ + const text = [ "text", "content", "body", "message", "title", "name", "displayName", "description", "summary", "label", ]; - for (const key of preferred) { + 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( - ([, value]) => typeof value === "string" && value.trim().length > 0, + ([key, value]) => + typeof value === "string" && value.trim().length > 0 && !skip.test(key), ); - return first ? `${first[0]}: ${String(first[1]).slice(0, 140)}` : "(no readable fields)"; + return first + ? `${first[0]}: ${String(first[1]).slice(0, 140)}` + : "(a record with no readable text)"; } /** @@ -85,3 +124,60 @@ export async function ownedByDomain(ename: string): Promise { }) .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/routes/api/request/+server.ts b/services/pp-auth-demo/src/routes/api/request/+server.ts index 41559ba6c..52f870f4c 100644 --- a/services/pp-auth-demo/src/routes/api/request/+server.ts +++ b/services/pp-auth-demo/src/routes/api/request/+server.ts @@ -2,6 +2,7 @@ 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"; @@ -19,6 +20,7 @@ export const POST: RequestHandler = async ({ request, locals }) => { deploymentEname?: string; domain?: string; operation?: string; + text?: string; }; const operation: Operation = body.operation === "write" ? "write" : "read"; const domain = String(body.domain ?? ""); @@ -71,7 +73,43 @@ export const POST: RequestHandler = async ({ request, locals }) => { grants, }); - return json({ stage: "authorised", chain, decision, missing: [] }); + 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 () => From e9dedf337fe7b58fcebdebb97c3b183d7b5b65f8 Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:48:35 +0800 Subject: [PATCH 22/29] feat: offer every published domain, and permit beside the request The domains a platform was not certified for are the ones worth asking for, so the list is the whole vocabulary with the uncertified ones marked. Read and write move next to the domain being asked about, replacing the matrix above it. Reputation has one service and no threshold, so it is stated rather than typed. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 8 +- docs/docs/W3DS Basics/Access-Policy.md | 4 +- .../pp-auth-demo/src/lib/RequestTester.svelte | 137 ++++++++++++++---- .../pp-auth-demo/src/lib/TermsForm.svelte | 41 ++---- services/pp-auth-demo/src/lib/server/env.ts | 10 ++ .../src/routes/acl/+page.server.ts | 21 ++- .../pp-auth-demo/src/routes/acl/+page.svelte | 107 ++------------ .../src/routes/api/terms/+server.ts | 18 +-- .../src/routes/terms/+page.server.ts | 3 +- .../src/routes/terms/+page.svelte | 6 +- 10 files changed, 183 insertions(+), 172 deletions(-) diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index ea4827269..ebf07ae1f 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -41,9 +41,11 @@ The table shows every certified platform against every kind of data you hold, de ## Permissions -Being certified for a kind of data is not permission to do anything with it. This tab is where that is settled: for each certified platform, a read and a write toggle per domain it was certified for. Nothing else is listed, because anything else is refused before permissions are consulted. +Being certified for a kind of data is not permission to do anything with it. This tab is where that is settled. -Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both toggles withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given. +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. + +Read and write are toggled beside the domain you are asking about. Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given. **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. @@ -55,7 +57,7 @@ Turn off write and a write is refused while a read still succeeds; withdraw the ## Your terms -The association says what a platform was found to be; you decide what that is worth. Set the minimum level, whose reputation scores you accept and the score they must reach, and any domain refused outright. +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. diff --git a/docs/docs/W3DS Basics/Access-Policy.md b/docs/docs/W3DS Basics/Access-Policy.md index 70db38fd1..ebcdaeb08 100644 --- a/docs/docs/W3DS Basics/Access-Policy.md +++ b/docs/docs/W3DS Basics/Access-Policy.md @@ -13,8 +13,8 @@ It is a signed statement rather than a stored setting, so it travels with the ow | 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 URL. Blank means reputation is not consulted at all. | -| `minimumReputation` | The score that engine must report for the platform. Null means no threshold. | +| `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. | diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte index 2cd687ce8..b9d9022ef 100644 --- a/services/pp-auth-demo/src/lib/RequestTester.svelte +++ b/services/pp-auth-demo/src/lib/RequestTester.svelte @@ -2,40 +2,93 @@ import ChainTrace from "./ChainTrace.svelte"; import type { ChainResult } from "@metastate-foundation/auth/platform"; + interface DomainGrant { + domain: string; + label: string; + /** Whether the association certified this platform for this domain. */ + certified: boolean; + read: boolean; + write: boolean; + revoked: boolean; + } + let { + platformEname, deployments, - domains, + grants, + onchange, }: { + platformEname: string; deployments: Array<{ ename: string; name: string; environment: string; keyHeld: boolean }>; - domains: Array<{ domain: string; label: string }>; + grants: DomainGrant[]; + onchange: () => Promise; } = $props(); let deploymentEname = $state(""); let domain = $state(""); let operation = $state<"read" | "write">("read"); + let text = $state(""); + + let busy = $state(false); + let saving = $state(false); + let chain = $state(null); + let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null); + let stage = $state(null); + let missing = $state([]); + let records = $state | null>(null); + let wrote = $state<{ id: string; kind: string } | null>(null); + let note = $state(null); - // Keep the selection valid as the lists change underneath it. Holding a - // stale eName would send the request against something no longer listed. + // Keep selections valid as the lists change underneath them. $effect(() => { if (!deployments.some((entry) => entry.ename === deploymentEname)) { deploymentEname = deployments[0]?.ename ?? ""; } }); $effect(() => { - if (!domains.some((entry) => entry.domain === domain)) { - domain = domains[0]?.domain ?? ""; + if (!grants.some((entry) => entry.domain === domain)) { + domain = grants.find((entry) => entry.certified)?.domain ?? grants[0]?.domain ?? ""; } }); - let busy = $state(false); - let chain = $state(null); - let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null); - let stage = $state(null); - let missing = $state([]); - let records = $state | null>(null); - let wrote = $state<{ id: string; kind: string } | null>(null); - let note = $state(null); - let text = $state(""); + let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false); + let selected = $derived(grants.find((entry) => entry.domain === domain) ?? null); + + function clear() { + chain = null; + decision = null; + stage = null; + records = null; + wrote = null; + note = null; + } + + async function permit(which: "read" | "write") { + if (!selected) return; + const next = { + read: selected.read, + write: selected.write, + [which]: !selected[which], + }; + saving = true; + try { + await fetch("/api/grants", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + platformEname, + domain, + operations: [ + ...(next.read ? ["read"] : []), + ...(next.write ? ["write"] : []), + ], + }), + }); + await onchange(); + } finally { + saving = false; + } + } async function send() { busy = true; @@ -58,8 +111,6 @@ busy = false; } } - - let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false);
@@ -68,7 +119,7 @@
+ {#if selected} +
+ {#if selected.certified} + You permit it to + + + {#if !selected.read && !selected.write} + + {selected.revoked ? "withdrawn" : "nothing yet"} + + {/if} + {:else} + + Not certified for {selected.label.toLowerCase()} data, so there is + nothing to permit — this is refused before permissions are consulted. + + {/if} +
+ {/if} + {#if operation === "write"}
-
-
- - (done = false)} /> -

Leave blank to ignore reputation entirely.

-
-
- - (done = false)} - placeholder="No threshold" - /> -
+
+

+ 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} diff --git a/services/pp-auth-demo/src/lib/server/env.ts b/services/pp-auth-demo/src/lib/server/env.ts index 8939ac78f..e091fd31c 100644 --- a/services/pp-auth-demo/src/lib/server/env.ts +++ b/services/pp-auth-demo/src/lib/server/env.ts @@ -45,6 +45,16 @@ 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"; } diff --git a/services/pp-auth-demo/src/routes/acl/+page.server.ts b/services/pp-auth-demo/src/routes/acl/+page.server.ts index e789b6c78..1112445c2 100644 --- a/services/pp-auth-demo/src/routes/acl/+page.server.ts +++ b/services/pp-auth-demo/src/routes/acl/+page.server.ts @@ -5,9 +5,12 @@ import { held } from "$lib/server/keys"; import type { PageServerLoad } from "./$types"; /** - * Everything needed to decide, and to see the decision: the certified - * platforms, the domains they were certified for, and what each has actually - * been permitted to do. + * 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; @@ -46,16 +49,18 @@ export const load: PageServerLoad = async ({ locals }) => { version: deployment.version, keyHeld: withKeys.has(deployment.deploymentEname), })), - grants: (record.domains ?? []).map((domain) => { + grants: domains.map((entry) => { + const domain = entry.id; const grant = grants.find( - (entry) => - entry.granteeEName === record.platformEName && - entry.resourceType === domain, + (held) => + held.granteeEName === record.platformEName && + held.resourceType === domain, ); const active = grant && grant.status === "active"; return { domain, - label: domains.find((d) => d.id === domain)?.label ?? 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"), diff --git a/services/pp-auth-demo/src/routes/acl/+page.svelte b/services/pp-auth-demo/src/routes/acl/+page.svelte index 3c45d0cc9..24e7e58ee 100644 --- a/services/pp-auth-demo/src/routes/acl/+page.svelte +++ b/services/pp-auth-demo/src/routes/acl/+page.svelte @@ -6,37 +6,9 @@ let { data }: { data: PageData } = $props(); - let saving = $state(null); - async function refresh() { await invalidateAll(); } - - async function toggle( - platformEname: string, - grant: { domain: string; read: boolean; write: boolean }, - which: "read" | "write", - ) { - const next = { read: grant.read, write: grant.write, [which]: !grant[which] }; - saving = `${platformEname}:${grant.domain}`; - try { - await fetch("/api/grants", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - platformEname, - domain: grant.domain, - operations: [ - ...(next.read ? ["read"] : []), - ...(next.write ? ["write"] : []), - ], - }), - }); - await refresh(); - } finally { - saving = null; - } - }
@@ -45,9 +17,9 @@

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, and this is - where that is decided. Each change is kept in your own eVault as a - permission record, so nothing is lost when you take access away. + 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.

@@ -69,6 +41,11 @@ Certified {platform.level} · {platform.version}

+
+ {#each platform.certifiedDomains as domain (domain)} + {domain} + {/each} +
{#if platform.deployments.length > 0} @@ -80,70 +57,18 @@ {/each} - {/if} - -
-

- Permissions -

-
- - - - - - - - - - - {#each platform.grants as grant (grant.domain)} - - - - - - - {/each} - -
DataReadWriteState
{grant.label} - toggle(platform.ename, grant, "read")} - /> - - toggle(platform.ename, grant, "write")} - /> - - {#if grant.read || grant.write} - permitted - {:else if grant.revoked} - withdrawn - {:else} - never given - {/if} -
-
-

- Only the data this platform was certified for is listed. Anything - else is refused before permissions are even consulted. -

-
- {#if platform.deployments.length > 0} ({ - domain: grant.domain, - label: grant.label, - }))} + grants={platform.grants} + onchange={refresh} /> + {: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/terms/+server.ts b/services/pp-auth-demo/src/routes/api/terms/+server.ts index 47a1bfcc7..919e60445 100644 --- a/services/pp-auth-demo/src/routes/api/terms/+server.ts +++ b/services/pp-auth-demo/src/routes/api/terms/+server.ts @@ -5,6 +5,7 @@ import { 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"; @@ -26,20 +27,14 @@ export const POST: RequestHandler = async ({ request, locals, url }) => { } const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter((v): v is string => typeof v === "string") : []; - const minimumReputation = - body.minimumReputation === null || body.minimumReputation === "" - ? null - : Number(body.minimumReputation); - if (minimumReputation !== null && !Number.isFinite(minimumReputation)) { - return json({ error: "The score must be a number" }, { status: 400 }); - } const statement = { ...defaultAccessPolicy(ename), minimumLevel: level, - reputationEngine: - typeof body.reputationEngine === "string" ? body.reputationEngine.trim() : "", - minimumReputation, + // 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(), @@ -52,8 +47,7 @@ export const POST: RequestHandler = async ({ request, locals, url }) => { { message: "Set the terms platforms must meet to reach your data", minimumLevel: statement.minimumLevel, - reputationEngine: statement.reputationEngine || "not used", - minimumReputation: statement.minimumReputation ?? "no threshold", + reputationFrom: statement.reputationEngine, refused: statement.deniedDomains.length ? statement.deniedDomains : "nothing", }, url.origin, diff --git a/services/pp-auth-demo/src/routes/terms/+page.server.ts b/services/pp-auth-demo/src/routes/terms/+page.server.ts index c8778b3ce..9b414e17c 100644 --- a/services/pp-auth-demo/src/routes/terms/+page.server.ts +++ b/services/pp-auth-demo/src/routes/terms/+page.server.ts @@ -1,4 +1,5 @@ import { listDomains } from "$lib/server/domains"; +import { reputationEngine } from "$lib/server/env"; import { currentPolicy } from "$lib/server/policy"; import type { PageServerLoad } from "./$types"; @@ -8,5 +9,5 @@ export const load: PageServerLoad = async ({ locals }) => { currentPolicy(ename), listDomains().catch(() => []), ]); - return { ename, policy, domains }; + 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 index 7c6a9e39e..76516bf79 100644 --- a/services/pp-auth-demo/src/routes/terms/+page.svelte +++ b/services/pp-auth-demo/src/routes/terms/+page.svelte @@ -28,7 +28,11 @@ {#key data.policy.statement.nonce} - + {/key} {#if data.policy.signed} From 8451e0edbe58b2d0035230e15c90aad78b207bcc Mon Sep 17 00:00:00 2001 From: coodos Date: Sun, 30 Aug 2026 18:51:20 +0800 Subject: [PATCH 23/29] fix: drop the permission toggles from the request panel Grants are the platform's to manage through the API; the panel sends requests and shows what came back. Claude-Session: https://claude.ai/code/session_01UpwygDu2cizLp12tvvKqVZ --- .../pp-auth-demonstrator.md | 8 +- .../pp-auth-demo/src/lib/RequestTester.svelte | 73 +------------------ .../pp-auth-demo/src/routes/acl/+page.svelte | 2 - 3 files changed, 8 insertions(+), 75 deletions(-) diff --git a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md index ebf07ae1f..a25e721db 100644 --- a/docs/docs/Post Platform Guide/pp-auth-demonstrator.md +++ b/docs/docs/Post Platform Guide/pp-auth-demonstrator.md @@ -45,7 +45,13 @@ Being certified for a kind of data is not permission to do anything with it. Thi 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. -Read and write are toggled beside the domain you are asking about. Each change writes an `AccessGrant` into your own eVault as a new revision. Clearing both withdraws the grant rather than deleting it, so the record shows access was taken away rather than never given. +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. diff --git a/services/pp-auth-demo/src/lib/RequestTester.svelte b/services/pp-auth-demo/src/lib/RequestTester.svelte index b9d9022ef..b2a1004e5 100644 --- a/services/pp-auth-demo/src/lib/RequestTester.svelte +++ b/services/pp-auth-demo/src/lib/RequestTester.svelte @@ -7,21 +7,15 @@ label: string; /** Whether the association certified this platform for this domain. */ certified: boolean; - read: boolean; - write: boolean; - revoked: boolean; } let { - platformEname, deployments, grants, - onchange, }: { - platformEname: string; deployments: Array<{ ename: string; name: string; environment: string; keyHeld: boolean }>; + /** Domains to offer, and whether the platform was certified for each. */ grants: DomainGrant[]; - onchange: () => Promise; } = $props(); let deploymentEname = $state(""); @@ -30,7 +24,6 @@ let text = $state(""); let busy = $state(false); - let saving = $state(false); let chain = $state(null); let decision = $state<{ allowed: boolean; reason: string; code: string } | null>(null); let stage = $state(null); @@ -52,7 +45,6 @@ }); let held = $derived(deployments.find((d) => d.ename === deploymentEname)?.keyHeld ?? false); - let selected = $derived(grants.find((entry) => entry.domain === domain) ?? null); function clear() { chain = null; @@ -63,33 +55,6 @@ note = null; } - async function permit(which: "read" | "write") { - if (!selected) return; - const next = { - read: selected.read, - write: selected.write, - [which]: !selected[which], - }; - saving = true; - try { - await fetch("/api/grants", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - platformEname, - domain, - operations: [ - ...(next.read ? ["read"] : []), - ...(next.write ? ["write"] : []), - ], - }), - }); - await onchange(); - } finally { - saving = false; - } - } - async function send() { busy = true; try { @@ -146,42 +111,6 @@ - {#if selected} -
- {#if selected.certified} - You permit it to - - - {#if !selected.read && !selected.write} - - {selected.revoked ? "withdrawn" : "nothing yet"} - - {/if} - {:else} - - Not certified for {selected.label.toLowerCase()} data, so there is - nothing to permit — this is refused before permissions are consulted. - - {/if} -
- {/if} - {#if operation === "write"}