Skip to content

Commit eb6845f

Browse files
authored
Merge pull request #157 from OpenVTC/vta-management
feat: VTA management console, proof-of-presence gating, and a registry-derived core
2 parents 3c15d6e + 9982173 commit eb6845f

81 files changed

Lines changed: 9936 additions & 341 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,78 @@ jobs:
6666
echo "OK: $bundle is a single bundle with no dynamic import()"
6767
6868
# `@openvtc/pnm-core/admin` is operator surface — granting authority at an
69-
# agent, revoking it, destroying contexts. A wallet has no business
70-
# shipping any of it, and the way it would arrive is someone importing it
71-
# from the package root instead of the subpath. The task URIs are the
72-
# tell: they only appear in a bundle that pulled the module in.
73-
- name: Assert the wallet ships no agent-administration surface
69+
# agent, revoking it, destroying contexts. The task URIs are the tell:
70+
# they only appear in a bundle that pulled the module in.
71+
#
72+
# The management console (`manager.html`) administers the agent, so it
73+
# imports the module deliberately. Every *wallet* surface — the service
74+
# worker, the content and page-world scripts, the popup, the confirm
75+
# window, the offscreen document, the options page — still must not, and
76+
# the way it would arrive is someone importing from the package root
77+
# instead of the subpath, or Rollup hoisting a shared chunk.
78+
#
79+
# Hence: banned everywhere in dist/ **except** `manager.js`. Phrased as an
80+
# exclusion rather than a list of permitted files so it keeps holding as
81+
# entries are added. `vite.config.manager.ts` builds the console alone
82+
# with `codeSplitting: false`, which is what makes "exactly one file may
83+
# contain this" a structural property rather than a convention.
84+
- name: Assert agent-administration surface is confined to the console
7485
run: |
7586
for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do
76-
if grep -rlF "$task" packages/extension/dist/; then
77-
echo "::error::the extension bundle contains $task — @openvtc/pnm-core/admin must not be reachable from the wallet (check for a root-barrel import)"
87+
leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true)
88+
if [ -n "$leaked" ]; then
89+
echo "::error::$leaked contains $task — @openvtc/pnm-core/admin must not be reachable from any wallet surface (check for a root-barrel import, or a shared chunk)"
7890
exit 1
7991
fi
8092
done
81-
echo "OK: no admin task URIs in the extension bundle"
93+
echo "OK: admin task URIs appear only in manager.js"
94+
95+
# A second, stricter guard — and the difference from the one above is the
96+
# point.
97+
#
98+
# That guard is about *authority*: `admin/*` grants and revokes it, and
99+
# the console is deliberately the one surface that holds it, so it names
100+
# `manager.js` as an exception.
101+
#
102+
# These tasks are about *material*. `vta/seeds/export-mnemonic/1.0`
103+
# returns a BIP-39 mnemonic — the seed every derived key in the agent
104+
# comes from — and `list`/`rotate` are the rest of that family's surface.
105+
# There is no browser context that should be able to ask for them, so this
106+
# guard has **no exception**: not the console, not the wallet, nowhere in
107+
# `dist/`.
108+
#
109+
# It exists because the alternative is an omission, and an omission is
110+
# indistinguishable from not having got to it yet. Someone reasonable
111+
# could add a seeds pane next year and no one would know it was refused on
112+
# purpose. This is what says so.
113+
#
114+
# `vault/release/0.1` is deliberately NOT here: it releases a secret to a
115+
# site the human just approved, which is the wallet's whole job.
116+
- name: Assert no key-material surface ships at all
117+
run: |
118+
for task in 'vta/seeds/list/1.0' 'vta/seeds/rotate/1.0' 'vta/seeds/export-mnemonic/1.0'; do
119+
found=$(grep -rlF "$task" packages/extension/dist/ || true)
120+
if [ -n "$found" ]; then
121+
echo "::error::$found contains $task — this family returns key material and must not ship in any extension bundle, the console included. See CLAUDE.md."
122+
exit 1
123+
fi
124+
done
125+
echo "OK: no key-material task URIs anywhere in dist/"
126+
127+
# The console's isolation rests on it being one self-contained file: the
128+
# guard above names exactly one exception, so a second chunk would be a
129+
# file nothing checks. Losing `codeSplitting: false` in a future upgrade
130+
# is silent otherwise.
131+
- name: Assert the console is a single self-contained bundle
132+
run: |
133+
bundle=packages/extension/dist/manager.js
134+
test -f "$bundle" || { echo "::error::$bundle was not emitted — did the manager build run?"; exit 1; }
135+
extra=$(ls packages/extension/dist/manager-split-*.js 2>/dev/null || true)
136+
if [ -n "$extra" ]; then
137+
echo "::error::the console emitted extra chunks ($extra); codeSplitting: false was lost and the admin guard now has unchecked files"
138+
exit 1
139+
fi
140+
echo "OK: manager.js is a single bundle"
82141
83142
# Build the Chrome Web Store upload artefact from the dist/ that the
84143
# Build step just produced (scripts/package.mjs re-stages it; it does

CLAUDE.md

Lines changed: 120 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ interaction code:
1515
- **`vti-stack-development-guide.md`** — binding rules (R-numbers below);
1616
paste its pre-merge checklist into PRs.
1717
- **`vti-networking-remediation-plan.md`** — deliverable **D8** covers this
18-
repo (with vti-didcomm-js and pnm-relay).
18+
repo (with vti-didcomm-js; `pnm-relay` was the third and no longer exists —
19+
see R4.1).
1920
- **`vti-architectural-direction.md`** — design-level rationale.
2021

2122
Rules that bite hardest here:
@@ -54,9 +55,22 @@ Rules that bite hardest here:
5455
network helper here takes an optional `fetch` for testability, so a literal
5556
`grep "fetch("` finds almost nothing — the real calls are spelled `f(...)`,
5657
`fetchFn(...)`, `this.fetchImpl(...)`.
57-
- **R4.1 — shared code with pnm-relay and vti-didcomm-js is a liability until
58-
extracted**: the relay never received this repo's body-first error-parsing
59-
fix. Land contract/transport fixes in all three or extract the shared core.
58+
- **R4.1 — the shared core is extracted; keep it that way.** This rule used to
59+
read "shared code with pnm-relay and vti-didcomm-js is a liability until
60+
extracted: the relay never received this repo's body-first error-parsing
61+
fix". That is done and the note had gone stale: **`pnm-relay` no longer
62+
exists.** Its `rest-channel.ts` / `request-task.ts` were consolidated into
63+
`@openvtc/pnm-core` — the copy `pnm-extension` and `pnm-pwa` both consume,
64+
which carries the body-first parse (`decodeTrustTaskHttpAck` reads the body,
65+
then builds with `errorFromBody`; `errorFromResponse` appears nowhere) and the
66+
`ConsentRequired` union. Nothing depends on `@openvtc/pnm-relay`, and
67+
`rp-sdk-js` is a separate server-side SIOPv2 verifier, not its successor.
68+
(`vti-networking-remediation-plan.md` F5, resolved by consolidation.)
69+
70+
What survives is the *rule*, not the defect: `vti-didcomm-js` is still a
71+
separate implementation of the same wire contract, so a transport or
72+
error-shape fix has to land in both. A third copy is what R4.1 exists to
73+
prevent — do not reintroduce one.
6074

6175
## How persist-before-ack is held (R1.6)
6276

@@ -128,6 +142,108 @@ routing either through a channel would overwrite or duplicate a proof.
128142
document as the counterparty receives it — a signature copied from another
129143
document satisfies an "is there a `proof` member" check and fails this one.
130144

145+
## The wallet ships no operator authority — the console does
146+
147+
`@openvtc/pnm-core/admin` is operator surface: granting authority at an agent,
148+
revoking it, destroying contexts. It is deliberately absent from the package
149+
root barrel, and CI greps the built output for 17 of its task URIs.
150+
151+
That guard used to read "banned anywhere in `dist/`", on the grounds that a
152+
wallet has no business shipping any of it. The **management console**
153+
(`manager.html`) makes that statement false on purpose — administering the agent
154+
is its whole job — so the guard was **narrowed, not deleted**: banned everywhere
155+
in `dist/` *except* `manager.js`. Every wallet surface (service worker, content
156+
and page-world scripts, popup, confirm, offscreen, options) keeps the property
157+
the guard was protecting.
158+
159+
**The console is its own vite build** (`vite.config.manager.ts`,
160+
`codeSplitting: false`). That is what makes "exactly one file may contain admin"
161+
structural rather than a convention: the main build emits popup, options,
162+
confirm and offscreen *together*, and Rollup is free to hoist shared code into a
163+
common `assets/*.js` chunk that wallet surfaces load. Building the console alone
164+
means there is no other entry to share with. A second CI assertion fails if it
165+
ever emits more than one chunk, because the first guard names exactly one
166+
exception and an extra chunk is a file nothing checks.
167+
168+
**The console holds no key material.** It composes typed documents with the
169+
`admin/*` helpers and the offscreen document signs them, so an XSS there cannot
170+
exfiltrate a key. This is why `admin/*` and `vta/contexts.ts` type their
171+
envelope parties as `TaskParty` (`vta/channel.ts`) — just a DID — rather than
172+
`Identity` and `RemoteDidcommEndpoint`: only `.did` was ever read, and a
173+
surface typed on `Identity` can only be called from somewhere holding a private
174+
key. The REST convenience wrappers (`vtaListContexts`, `vtaCreateContext`) still
175+
take the stricter pair, because they *build a channel*, and a channel signs.
176+
177+
**Only `type` and `payload` cross the bridge.** `RUNTIME_MANAGER_TASK` carries
178+
those two members and nothing else; `carrier.ts` strips the envelope the admin
179+
helper built, and `offscreen.ts`'s existing `OFFSCREEN_REQUEST_TASK` mints the
180+
real one and signs it. `core/src/vta/request-task.ts` explains why the device
181+
must mint it, and that reasoning does not soften because the composer is an
182+
extension page: a wallet that counter-signs a document composed elsewhere
183+
attests to fields it never checked. Reusing that path also inherits transport
184+
selection, `TransportHealth`, and the same-browser approver ceremony for free —
185+
`offscreen.ts` needed no change at all.
186+
187+
**The relay is gated on `sender.url`, not `sender.id`.** Every content script
188+
carries this extension's id, so `sender.id` cannot separate a page from an
189+
extension surface. `isExtensionPageSender` compares against
190+
`chrome.runtime.getURL("")`. Unlike the page-facing `RUNTIME_REQUEST_TASK`, this
191+
one does **not** prompt per call — the caller is the operator driving their own
192+
console, and twelve identical dialogs to render one screen is dismissal, not
193+
consent. What stands in its place: the agent's ACL, its policy engine (a
194+
`requireConsent` comes back as `ConsentRequiredError` and renders as a match-code
195+
ceremony, never as a red string), and preview-then-confirm on every irreversible
196+
action, showing the agent's own account of what would be destroyed.
197+
198+
**What breaks it:** importing `admin` from the package root instead of the
199+
subpath; folding `manager.html` into `vite.config.ts` (a shared chunk then
200+
carries admin into wallet surfaces); losing `codeSplitting: false`; adding
201+
`RUNTIME_MANAGER_TASK` to `PAGE_FACING_RUNTIME_TYPES` or to `content.ts`'s
202+
dispatch table; gating on `sender.id`; or widening the carrier to pass the
203+
envelope through. `tests/manager-sender.test.mts`,
204+
`tests/manager-surface.test.mts` and the two CI assertions pin each of these.
205+
206+
## Key material never reaches a browser, and that is enforced
207+
208+
`vta/seeds/*``list`, `rotate`, `export-mnemonic` — is the one task family
209+
this extension refuses outright. `export-mnemonic` returns a BIP-39 mnemonic:
210+
the seed every derived key in the agent comes from, and the one secret whose
211+
disclosure loses everything at once. `list` and `rotate` are the rest of that
212+
family's surface.
213+
214+
**A second CI guard bans all three from anywhere in `dist/`, with no
215+
exception.** That is the difference from the admin guard above, and the
216+
difference is the point: `admin/*` is *authority*, which the console is meant to
217+
hold, so that guard names `manager.js` as its one permitted file. These return
218+
*material*, and no browser context should be able to ask for them — not the
219+
console, not the wallet, nowhere.
220+
221+
**Why a guard rather than simply not building it.** Not building a seeds pane is
222+
indistinguishable from not having got round to one. Someone reasonable adds it
223+
next year, nothing objects, and the refusal was never recorded anywhere a person
224+
would look. The guard is what makes the decision legible.
225+
226+
**Verified non-vacuous — and the way it is verified matters.** A seeds URI
227+
merely *present* in console source is not enough: Rollup tree-shakes an
228+
unreferenced export, the string never reaches `dist/`, and the guard correctly
229+
stays silent. That is the guard being right, not weak — it asserts what
230+
*ships* — but it means a probe that adds an unused `export const` proves
231+
nothing and reads like a hole. To re-verify, put the URI somewhere the console
232+
actually renders (a nav `label`, say), rebuild, and watch `manager.js` trip it.
233+
234+
`packages/core` has no seeds module and must not gain one. The guard catches
235+
that too — a core function would be bundled into `manager.js` and grep would
236+
find it there.
237+
238+
**`vault/release/0.1` is deliberately not on the list.** It releases a secret to
239+
a site the human has just approved, which is the wallet's entire job. The line
240+
is not "touches a secret"; it is "hands over material the holder cannot revoke,
241+
to a surface that cannot contain it".
242+
243+
**What breaks it:** adding a seeds client to `packages/core`; relaxing the guard
244+
to allow `manager.js` "for symmetry" with the admin one; or reading this as
245+
advice rather than a refusal.
246+
131247
## Advertisement is not availability
132248

133249
A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel

package-lock.json

Lines changed: 10 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,5 +26,8 @@
2626
"overrides": {
2727
"uuid": "^11.1.1",
2828
"esbuild": "^0.28.1"
29+
},
30+
"dependencies": {
31+
"@openvtc/trust-tasks": "^0.16.8"
2932
}
3033
}

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@
120120
"dependencies": {
121121
"@cfworker/json-schema": "^4.1.1",
122122
"@noble/curves": "^2.4.0",
123-
"@openvtc/trust-tasks": "^0.16.3",
123+
"@openvtc/trust-tasks": "^0.16.8",
124124
"@openvtc/vti-didcomm-js": "^0.7.0",
125125
"@openvtc/vti-tsp-js": "^0.2.0",
126126
"@scure/base": "^2.2.0",

packages/core/src/admin/acl.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@
1212
// from the Rust structs, which is a copy that drifts, and got the nullability of
1313
// `acl/show`'s response wrong in the process.
1414

15-
import type { Identity } from "../didcomm/index.js";
16-
import type { TrustTaskSender } from "../vta/channel.js";
17-
import type { RemoteDidcommEndpoint } from "../vta/didcomm.js";
15+
import type { TaskParty, TrustTaskSender } from "../vta/channel.js";
1816
import { buildTrustTask } from "../vta/trust-task.js";
1917

2018
import {
@@ -73,9 +71,9 @@ export type { AclEntry };
7371
export interface AclCallerParams {
7472
/** Envelope `issuer` — the caller's DIDComm identity. Its DID needs a role
7573
* the agent accepts for this task; the whole family is manage-gated. */
76-
holder: Identity;
74+
holder: TaskParty;
7775
/** The agent — envelope `recipient`. */
78-
service: RemoteDidcommEndpoint;
76+
service: TaskParty;
7977
}
8078

8179
export interface AclGrantParams extends AclCallerParams {

0 commit comments

Comments
 (0)