From 8b9f4ab3ae49c8c0d922bbf881295bdfc6d34dcf Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Mon, 14 Sep 2026 19:37:26 +0200 Subject: [PATCH] feat(manager): realign a DID's key records from the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives `vta/webvh/dids/realign-keys/1.0` (specified at trustoverip/dtgwg-trust-tasks-tf#456, served by OpenVTC/verifiable-trust-infrastructure#1466 and dispatched by #1470) from the DIDs pane. ## The dry run is the diagnosis, not a courtesy Nothing on this screen tells an operator whether a DID needs realigning: the answer lives in the agent's key store and the DID's published log, and comparing them is precisely what the task does. So the preview *is* the feature — a consistent DID says so, and one that is not lists every rename before any of them happen. Which is why the action is offered on every DID rather than only where the console thinks it is needed. Deciding that here would mean a second, client-side copy of the comparison, and a copy that disagreed with the agent's would be invisible to the operator. ## A repair is not a destruction `Destructive` was the obvious home — it is already ask-then-confirm, and it already routes a consent refusal to the ceremony instead of a red string. But its voice is deletion throughout: a danger button, "what this would destroy", a danger note. A realign renames records and leaves the key material untouched, and drawing that in red is the lie `manager-theme.css` forbids, where `--w-danger` is one of three colours that mean something. So the shape of the decision stays shared and the voice became a parameter: `nature: "corrective"` asks what would *change* and draws in the accent. No force tick, because there is no refusal to override. A second component would have been a second place for the consent-refusal handling to be forgotten. ## The three outcomes are kept apart `moved` empty with `unmatched` non-empty is **not** "nothing to change" — it is an agent that cannot finish the repair because it holds no key for a method the document publishes. Both have an empty `moved` and the reassuring reading is the wrong one, so the pane says which it is. `dids-realign.render.test.mts` asserts each of the three against the words on screen. ## The snapshot moves more than this task `task-surface.json` is re-synced (vta-sdk 0.35.0 → 0.38.0) and the coverage expectation with it, 204 → 205. The canonical *total* jumps 227 → 235, and only one of those eight is this task: the other seven were always in the SDK and invisible to the old scanner, which matched only string literals while vta-sdk increasingly derives a constant from the generated payload type. The scanner learned to resolve those in the previous commit, so this is not the agent growing seven families — it is a denominator that had been under-counting the gap this number exists to keep reviewable. Nothing is lost in the resync: all 245 tasks in the old snapshot survive. Signed-off-by: Glenn Gore --- package-lock.json | 14 +- packages/core/package.json | 4 +- packages/core/src/webvh/dids.ts | 35 +++++ packages/core/task-surface.json | 76 +++++++++- packages/core/tests/task-surface.mjs | 15 +- .../extension/src/manager/destructive.tsx | 39 ++++- packages/extension/src/manager/panes/dids.tsx | 101 ++++++++++++- .../tests/dids-realign.render.test.mts | 138 ++++++++++++++++++ 8 files changed, 403 insertions(+), 19 deletions(-) create mode 100644 packages/extension/tests/dids-realign.render.test.mts diff --git a/package-lock.json b/package-lock.json index aa40ac1..d2429d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2304,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.19.1.tgz", - "integrity": "sha512-p0e1mbmhI+JJ/vFaG5Y+X1LvrK8bB4vi+JF622wFRyyym2Brcvffa5Kfc5N5VPqi5d9E0HF8fza4SCHTbJU3gg==", + "version": "0.19.5", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.19.5.tgz", + "integrity": "sha512-zNL532p+NSZ73MkoyZz9kegDRxQXRF7NzTpZBfSVP/PgS6DBUoi48UjzdEHqjVWhHHMtiKlaaYc2vKZzicSiQA==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -8034,7 +8034,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.18.3", + "@openvtc/trust-tasks": "^0.19.5", "@openvtc/vti-didcomm-js": "^0.8.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", @@ -8046,12 +8046,6 @@ "typescript": "^7.0.2" } }, - "packages/core/node_modules/@openvtc/trust-tasks": { - "version": "0.18.3", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.18.3.tgz", - "integrity": "sha512-iECnJWi9LJ1DqMjYdunJC1BdaTzVkFRU7ngD2DBl3oPxpPOfiayCSKboKFv3aF3/lBBOnpSIV76emVAgs4yjSg==", - "license": "Apache-2.0" - }, "packages/demo-rp": { "name": "@openvtc/pnm-demo-rp", "version": "0.1.0", diff --git a/packages/core/package.json b/packages/core/package.json index 59fc799..20d151c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@openvtc/pnm-core", "version": "0.9.1", - "description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE→Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.", + "description": "Browser-side bridge between WebAuthn passkeys and VTA-managed DIDs. Wire types, WebAuthn ceremony helpers, COSE\u2192Multikey conversion, DID verificationMethod builder, REST + DIDComm transports, mediator client, SIOP / RP-login / provision-integration flows.", "license": "Apache-2.0", "repository": { "type": "git", @@ -130,7 +130,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.18.3", + "@openvtc/trust-tasks": "^0.19.5", "@openvtc/vti-didcomm-js": "^0.8.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/packages/core/src/webvh/dids.ts b/packages/core/src/webvh/dids.ts index 666bd93..2363d3d 100644 --- a/packages/core/src/webvh/dids.ts +++ b/packages/core/src/webvh/dids.ts @@ -33,6 +33,12 @@ import { type Payload as DidsListPayload, type Response as DidsListResponse, } from "@openvtc/trust-tasks/vta/webvh/dids/list/1.0/payload"; +import { + TYPE_URI as DIDS_REALIGN_KEYS, + RESPONSE_TYPE_URI as DIDS_REALIGN_KEYS_RESPONSE, + type Payload as DidsRealignKeysPayload, + type Response as DidsRealignKeysResponse, +} from "@openvtc/trust-tasks/vta/webvh/dids/realign-keys/1.0/payload"; import { TYPE_URI as DIDS_UPDATE, RESPONSE_TYPE_URI as DIDS_UPDATE_RESPONSE, @@ -274,3 +280,32 @@ export async function webvhDidRegisterWithServer( DIDS_REGISTER_WITH_SERVER_RESPONSE, "vta/webvh/dids/register-with-server/1.0", payload); } + +/** + * Rename a DID's key records onto the verification-method ids its published + * document declares. + * + * **The repair for records the agent named without reading its own document.** + * A key record's id is a verification-method id — it is what a mediator matches + * an inbound JWE recipient against, and what anyone reading the document would + * hand to a signing task. Where the two disagree, the agent holds a key the + * document addresses under a name the agent does not answer to. + * + * `keys/rename` cannot fix that and is not meant to: its identifier gate refuses + * `:` and `#` so that a rename is not a way to write verification-method-shaped + * names into a key store. So this call names nothing — `did` and `dryRun` are + * the whole payload, and the agent derives every target from that DID's own + * published log, matching records to methods by public key. + * + * **Send it once with `dryRun` before sending it without.** The response is the + * same shape either way and says which it is, so a caller can show the plan and + * then apply exactly what was shown. + */ +export async function webvhDidRealignKeys( + sender: TrustTaskSender, + params: WebvhCall & DidsRealignKeysPayload, +): Promise { + const { holder, service, ...payload } = params; + return send(sender, { holder, service }, DIDS_REALIGN_KEYS, DIDS_REALIGN_KEYS_RESPONSE, + "vta/webvh/dids/realign-keys/1.0", payload); +} diff --git a/packages/core/task-surface.json b/packages/core/task-surface.json index e0052c4..6697439 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -2,7 +2,7 @@ "$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.", "source": { "crate": "vta-sdk", - "version": "0.35.0", + "version": "0.38.0", "scanned": "vta-sdk/src/**/*.rs" }, "tasks": [ @@ -56,6 +56,12 @@ "TASK_AUDIT_LIST_0_1" ] }, + { + "uri": "https://trusttasks.org/spec/audit/verify/0.1", + "consts": [ + "TASK_AUDIT_VERIFY_0_1" + ] + }, { "uri": "https://trusttasks.org/spec/auth/authenticate/0.1", "consts": [ @@ -1021,6 +1027,26 @@ "TASK_VAULT_UPSERT_0_3" ] }, + { + "uri": "https://trusttasks.org/spec/vetting/decline/0.1", + "consts": [ + "VETTING_DECLINE_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vetting/request/0.1", + "consts": [ + "VETTING_REQUEST_RESPONSE_TYPE", + "VETTING_REQUEST_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vetting/session/0.1", + "consts": [ + "VETTING_SESSION_RESPONSE_TYPE", + "VETTING_SESSION_TYPE" + ] + }, { "uri": "https://trusttasks.org/spec/vta/app-state/delete/1.0", "consts": [ @@ -1381,6 +1407,12 @@ "TASK_WEBVH_DIDS_LIST_1_0" ] }, + { + "uri": "https://trusttasks.org/spec/vta/webvh/dids/realign-keys/1.0", + "consts": [ + "TASK_WEBVH_DIDS_REALIGN_KEYS_1_0" + ] + }, { "uri": "https://trusttasks.org/spec/vta/webvh/dids/register-with-server/1.0", "consts": [ @@ -1443,6 +1475,13 @@ "JOIN_REQUEST_MANIFEST_TYPE" ] }, + { + "uri": "https://trusttasks.org/spec/vtc/join-requests/manifest/0.2", + "consts": [ + "JOIN_REQUEST_MANIFEST_0_2_RESPONSE_TYPE", + "JOIN_REQUEST_MANIFEST_0_2_TYPE" + ] + }, { "uri": "https://trusttasks.org/spec/vtc/join-requests/status/0.1", "consts": [ @@ -1494,6 +1533,41 @@ "MEMBER_VMC_RESPONSE_TYPE", "MEMBER_VMC_TYPE" ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/vetting/revoke-statement/0.1", + "consts": [ + "VETTING_REVOKE_STATEMENT_RESPONSE_TYPE", + "VETTING_REVOKE_STATEMENT_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/vetting/vetters/grant/0.1", + "consts": [ + "VETTING_VETTER_GRANT_RESPONSE_TYPE", + "VETTING_VETTER_GRANT_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/vetting/vetters/list/0.1", + "consts": [ + "VETTING_VETTER_LIST_RESPONSE_TYPE", + "VETTING_VETTER_LIST_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/vetting/vetters/profile/0.1", + "consts": [ + "VETTING_VETTER_PROFILE_RESPONSE_TYPE", + "VETTING_VETTER_PROFILE_TYPE" + ] + }, + { + "uri": "https://trusttasks.org/spec/vtc/vetting/vetters/resend/0.1", + "consts": [ + "VETTING_VETTER_RESEND_RESPONSE_TYPE", + "VETTING_VETTER_RESEND_TYPE" + ] } ] } diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index 573c60e..d10340d 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -347,7 +347,20 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // address (dtgwg-trust-tasks-tf#426). The third is `rooms/keys/present/0.2` // and `rooms/owner/issue-authority/0.2` counting as new families beside the // 0.1 this library had drifted behind. - const expected = 204; + // + // 204 → 205 is `vta/webvh/dids/realign-keys/1.0` — the repair for a DID whose + // key records are not named after the verification methods it publishes + // (dtgwg-trust-tasks-tf#456, VTI#1466 and #1470). + // + // **The canonical total jumps 227 → 235 in the same commit, and only one of + // those eight is this task.** The other seven were always in the SDK and + // invisible to the scanner: `vta-sdk` derives a growing number of constants + // from the generated payload type rather than writing the URI out, and + // `sync-task-surface.mjs` matched only literals until it was taught to + // resolve them. So this is not the agent growing seven families — it is a + // snapshot that had been under-counting the denominator, and with it the gap + // this number exists to keep reviewable. + const expected = 205; assert.equal( implemented.size, expected, diff --git a/packages/extension/src/manager/destructive.tsx b/packages/extension/src/manager/destructive.tsx index 6ecc9f3..ea6584d 100644 --- a/packages/extension/src/manager/destructive.tsx +++ b/packages/extension/src/manager/destructive.tsx @@ -14,6 +14,20 @@ // because the agent refuses a non-empty deletion on purpose and overriding that // refusal is a second decision. // +// ## `nature`, and why a second component was not written +// +// Not every two-step action is a destruction. Realigning a DID's key records +// asks the agent for the same thing — what would change, before it changes — +// and then applies it, but nothing is lost: records are renamed and the key +// material behind them is untouched. +// +// Drawing that in red would be a lie of exactly the kind `manager-theme.css` +// forbids, where `--w-danger` is one of three colours that mean something. So +// the *shape* of the decision is shared and the **voice** is a parameter: +// `nature: "corrective"` asks what would change rather than what would be +// destroyed, and draws in the accent rather than in danger. It gets no `force` +// tick, because there is no refusal to override. +// // ## Why `consentRequired` renders here // // The agent may answer "a human must approve this first". That is not a @@ -77,6 +91,15 @@ export function ConsentCeremony({ pending }: { pending: ConsentRequiredError }) export interface DestructiveProps

{ /** Button copy for the action itself, e.g. "Delete context". */ label: string; + /** + * What kind of action this is, which decides the voice and the colour. + * + * `destructive` (the default) is the one this component was written for: + * something is lost, so the preview asks what would be destroyed and is drawn + * in danger. `corrective` is a repair — the preview asks what would change, + * and drawing it in danger would overstate it. + */ + nature?: "destructive" | "corrective"; /** Disabled reason, or null when the action is available. Shown rather than * hiding the control — see `hasRole` in `use-vta.ts`. */ disabledReason?: string | null; @@ -112,6 +135,7 @@ type Phase

= */ export function Destructive

({ label, + nature = "destructive", disabledReason = null, preview, renderPreview, @@ -122,6 +146,7 @@ export function Destructive

({ }: DestructiveProps

) { const [phase, setPhase] = useState>({ kind: "idle" }); const [force, setForce] = useState(false); + const corrective = nature === "corrective"; const reset = useCallback(() => { setPhase({ kind: "idle" }); @@ -162,7 +187,7 @@ export function Destructive

({ if (phase.kind === "idle") { return (