Skip to content

feat: verify every approval leg against the enrolled-executor set - #103

Merged
stormer78 merged 1 commit into
mainfrom
consent/enrolled-executors
Jul 29, 2026
Merged

feat: verify every approval leg against the enrolled-executor set#103
stormer78 merged 1 commit into
mainfrom
consent/enrolled-executors

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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:

  1. Enrolled-executor set replaces the single-VTA pin. ParseTaskConsentOptions.expectedVtaDidenrolledExecutorDids; 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.vtaDidexecutorDid (the proven signer), and the signed decision now routes back to executorDid — identical for the classic flow, correct when a control plane is the issuer.
  2. Strict step-up verification. The start payload is fetched over authenticated REST and trusted for nothing. New verifyStepUpApproveRequest requires the signed document (approve-request/0.1 or /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 legacy subject/sessionId/challenge, enforces payload.expiresAt when present, and renders only from the verified payload. No document → refuse with a clear log line; the proofless path is removed deliberately (code comment says so).
  3. confirm/request/0.1 fallback removed — the family is retired ecosystem-wide (superseded by task-consent). Parser, exports, tests, and the RUNTIME_INBOUND_CONSENT bridge 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; full npm run build clean.

Merge notes

  • Merge/ship only after the sender PRs deploy — a control plane that doesn't yet send document fails closed here.
  • Operator action at rollout: add the control plane's DID under Options → "Enrolled executor DIDs"; step-up against it fails closed until then.
  • Follow-up flagged: the step-up consent prompt currently fires before the start fetch, so the verified reason isn't rendered yet — surfacing it needs a small background↔offscreen reorder.
  • reason is kept optional in the verified request (spec marks it REQUIRED; flag if it should be hard-required).

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
stormer78 force-pushed the consent/enrolled-executors branch from d780b64 to c79b496 Compare July 29, 2026 07:56
@stormer78
stormer78 merged commit a2061b3 into main Jul 29, 2026
3 checks passed
@stormer78
stormer78 deleted the consent/enrolled-executors branch July 29, 2026 08:05
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant