feat: verify every approval leg against the enrolled-executor set - #103
Merged
Conversation
Every approval request this wallet renders must be a Trust Task document signed by an executor the wallet is enrolled with — verified BEFORE any human sees a prompt; unverifiable requests are dropped, never rendered. Three coordinated changes: - task-consent: generalize the single-VTA signer pin to an enrolled-executor set (the onboarded VTA DIDs threaded in via OFFSCREEN_START_INBOUND, the default step-up VTA, plus operator-enrolled executor DIDs — a new WalletSettings.enrolledExecutorDids list with an options-page editor). A webvh DID-hosting control plane signs task-consent requests now, so 'signer == this session's VTA' became 'signer ∈ enrolled executors'; unknown signer still rejects with untrusted_issuer and never prompts. Decisions route back to the proven signer (parsed.executorDid), which for the classic flow is the VTA. - step-up: the RP start response now carries a signed auth/step-up/approve-request/0.2 document; verifyStepUpApproveRequest requires it, verifies the eddsa-jcs-2022 assertionMethod proof, requires issuer == proven signer ∈ enrolled executors (and == the page-supplied rpDid), cross-checks the legacy subject/sessionId/challenge copies, and builds the request the wallet signs over ONLY from the verified payload — the spec's 'verify the proof BEFORE surfacing the reason' rule. The proofless legacy shape is refused outright (removed deliberately; ships after the control-plane change that always returns the document). The VTA-pushed approve-request/0.1 flavor passes the same gate. - confirm/request/0.1: remove the retired family entirely — the inbound fallback after a task-consent rejection, the core confirm module, the RUNTIME_INBOUND_CONSENT bridge leg, and their tests. Retired ecosystem-wide (registry supersededBy task-consent); a retired, RP-authored prompt path is exactly what an attacker would reach for once the strict path shuts them out. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
force-pushed
the
consent/enrolled-executors
branch
from
July 29, 2026 07:56
d780b64 to
c79b496
Compare
stormer78
added a commit
that referenced
this pull request
Jul 29, 2026
The flagged follow-up from #103: the step-up consent prompt fired before the RP start fetch, so the human decided on origin/rpDid alone and the signed reason — the thing #103 made verifiable — was never shown. The spec's rule is 'consumers MUST verify the proof BEFORE surfacing the reason'; we verified but never surfaced. Reorder the flow so consent sits between verification and signing: - core: new performStepUpVta owns the enforced order — start -> verify (signed approve-request; proof + enrolled-executor signer + issuer == page rpDid) -> consent callback -> sign approve-response -> finish. A refused approve-request returns before the callback, so no prompt is ever raised for unverifiable content; a decline sends nothing (the RP's challenge lapses on its TTL). Unit-tested against a mock RP: prompt content comes from inside the signature (a tampered unsigned copy is never shown), declined sends nothing, missing document and issuer/rpDid mismatch both refuse without prompting. - extension background: handleStepUpVta no longer pre-prompts; it forwards to the offscreen, threading the browser-attested origin. The new mid-flow RUNTIME_STEP_UP_CONSENT (offscreen -> background) raises the prompt through the same gatedConsent gate as before — the 'remember this site' origin-trust short-circuit keeps its pre-#103 semantics — with the verified reason length-capped (500 chars) and control/bidi-character-stripped before it reaches the popup. - confirm popup: step-up framing ('Step-up approval request') plus a reason card that renders the RP's verified reason as plain text (React text nodes, no markup), visually attributed to the verified RP DID card with an explicit 'their claim' note. A document with no reason falls back to the previous origin/rpDid-only prompt. Security invariants from #103 unchanged: no prompt on missing/invalid document, issuer/rpDid binding intact, response signing only after explicit user approval. Part of the step-up programme (affinidi/affinidi-webvh-service#147). Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
added a commit
to OpenVTC/verifiable-trust-infrastructure
that referenced
this pull request
Jul 29, 2026
…tays bilingual (#873) The deferred follow-up to #870: mint_pending_step_up now emits auth/step-up/approve-request/0.2 — the /0.2 type URI and the camelCase acceptableEvidence spelling (didSigned), which is the only payload difference between the minors. Signing is unchanged (eddsa-jcs-2022, assertionMethod, {vta_did}#key-0, proof last over the complete document including payload.ext, so the Cierge authorizationContext carriage stays covered by the proof). The DIDComm push type follows the document. Receivers moved first: vta-mobile-core (#871) and the browser plugin (OpenVTC/vta-browser-plugin#103) accept both request minors, and the webvh control plane (affinidi/affinidi-webvh-service#147) accepts both approve-response minors. Inbound stays bilingual for the transition window: - the approve-response dispatcher keeps accepting 0.1 and 0.2; - the DIDComm router's canonical step-up-approve registration now also accepts the /0.2 request URI beside /0.1 and the legacy vta/step-up/*/1.0, echoing the caller's own minor in the response. The stored PendingStepUp.acceptable_evidence keeps the internal kebab canonical form (state, not wire), so in-flight 0.13.17 pending step-ups remain consumable across the deploy. New integration test: the gate's minted 0.2 document verifies end-to-end (di_proof; issuer == proof VM DID) and a 0.1-flavored signed approve-response completes the 0.2-minted step-up, acking in the approver's 0.1 family. vta-service 0.13.17 -> 0.13.18. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The browser-approver half of the "signed request legs" programme (affinidi/affinidi-webvh-service#147; senders: affinidi/affinidi-webvh-service#149 and OpenVTC/verifiable-trust-infrastructure#870). Three changes:
ParseTaskConsentOptions.expectedVtaDid→enrolledExecutorDids; the signer check is set-membership, failure mode unchanged (untrusted_issuer, logged, no prompt). The effective set (enrolledExecutorDids()in offscreen): session VTA + all onboarded VTA DIDs +defaultStepUpVtaDid+ a new operator-editable "Enrolled executor DIDs" list (Options page, IndexedDB-backed so offscreen can read it; saved as an always-written array so un-enrolling revokes). A webvh control plane is enrolled via that list.ParsedTaskConsentRequest.vtaDid→executorDid(the proven signer), and the signed decision now routes back toexecutorDid— identical for the classic flow, correct when a control plane is the issuer.verifyStepUpApproveRequestrequires the signeddocument(approve-request/0.1or/0.2), verifies the proof (eddsa-jcs-2022, assertionMethod), requires issuer == proven signer ∈ enrolled set and issuer == the page's claimed RP DID, cross-checks legacysubject/sessionId/challenge, enforcespayload.expiresAtwhen present, and renders only from the verified payload. Nodocument→ refuse with a clear log line; the proofless path is removed deliberately (code comment says so).confirm/request/0.1fallback removed — the family is retired ecosystem-wide (superseded by task-consent). Parser, exports, tests, and theRUNTIME_INBOUND_CONSENTbridge leg deleted; only offscreen used them.Tests
npm run lint(tsc -b, all workspaces) exit 0;npm test: core 176/176 (new: control-plane signer accepted; non-enrolled signer →untrusted_issuer; step-up without document refused; valid signed document rendered from the document, not the tampered legacy copy; 0.1 flavor; tampered/lapsed/mismatch refusals), tsp-js 42/42; fullnpm run buildclean.Merge notes
documentfails closed here.reasonisn't rendered yet — surfacing it needs a small background↔offscreen reorder.reasonis kept optional in the verified request (spec marks it REQUIRED; flag if it should be hard-required).