Skip to content

feat(persona): finish a release: stepUp disclosure instead of failing at the last hop - #198

Merged
stormer78 merged 2 commits into
mainfrom
feat/disclosure-step-up-ceremony
Sep 8, 2026
Merged

feat(persona): finish a release: stepUp disclosure instead of failing at the last hop#198
stormer78 merged 2 commits into
mainfrom
feat/disclosure-step-up-ceremony

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

A release: stepUp disclosure through the wallet now completes instead of
failing at the last hop.

First, a correction

I told you nothing drove a disclosure in this wallet. That was wrong. I had
grepped for the core client functions (previewDisclosure/presentDisclosure);
background.ts calls the tasks directly through runPersonaTask, and
handleDisclose — preview → consent surface → present — has been complete for
some time, with disclosure-consent.tsx rendering buildConsentView and
page-task-policy.ts keeping a page off the family.

The actual gap was narrower and worse: handleDisclose did

if (!presented.ok) return { ok: false, error: presented.error };

so a stepUpRequired refusal became a prose error handed to the page. The
holder approved on the consent screen, the agent asked for the fresh
authentication payment.* and gov.* require, and the ceremony this stack
spent four repositories building was never driven.

That is the exact failure vta/request-task.ts warns about for the consent
refusal it handles: "the caller shows the user 'Error: consent_required',
strands them at exactly the moment they were supposed to act, and the entire
informed-consent flow is discarded at the last hop."

What it does now

preview → consent surface → present
                              ↓ stepUpRequired
                    verify → prompt → approve → present (same previewId)
  • runPersonaTask keeps the structured refusal. It returns RelayTaskFailure
    rather than the page-facing prose shape — the wallet has to tell one refusal
    from another (R3.7). None of it reaches the page: handleDisclose still
    returns prose or the presentation, exactly as handleRequestTask narrows the
    same members off for a site.
  • Verify, then show, then sign — the order doStepUpVta already enforces
    for the RP step-up. Everything the holder reads comes from inside the
    agent's signature, and verifyDisclosureStepUp additionally refuses a request
    whose signed previewId is not the one the refusal named. A refused request
    returns before any prompt, so the holder is never shown a claim list this
    wallet could not verify.
  • The same previewId is retried. The refusal did not consume it —
    previewRetained: true — which is why this is a retry and not a restart.

The prompt does not use gatedConsent, and that is the point

gatedConsent returns true outright for an origin the holder ticked
"remember this site" for. Right for a login step-up; wrong here. release: stepUp exists to make the holder decide each time, and an origin-level grant
answering for them turns "each time" into "once per site" — the same failure as
binding to the session, reached from a different direction.

So this calls requestConsent directly, with noRemember: true so it cannot
become one. There is nothing to remember: the approval is bound to a single
previewId and dies with it.

No second proof

The approve-response goes as an ordinary Trust Task. The channel signs every
outbound document as the holder with proofPurpose: "assertionMethod", which is
precisely the gate approve-response requires — so disclosureApprovalPayload
returns a payload, not a document. Building a proof here would duplicate or
overwrite the channel's, which is the reason provision/integration is called
out in this repo's guide as the case that must bypass a channel. A test asserts
the payload carries no proof and no type.

It is minted as 0.3, so a bound approval is answered recorded and elevates
nothing (VTI #1316, plugin #193).

Tests, and what is not covered

Core, 3 new (16 in the file): the shared recognition from a non-thrown refusal,
including that it is exactly as strict as the thrown path; the approval echoing
only verified fields and carrying no proof; and a denial being expressible.

disclosureStepUpFrom exists so the thrown and non-thrown paths share one rule
— a second implementation would be the same three checks written twice, drifting
on the third change rather than the first.

Not covered: the background wiring itself. background.ts has no unit
tests in this repo — it is a service worker against chrome.* — so
"a trusted origin does not skip this prompt" and "the retry reuses the same
previewId" are structural rather than asserted. I extracted what could be
extracted into core and tested it there; I am flagging the remainder rather than
implying coverage it does not have.

tsc -b clean, npm run build clean, 979 tests across four workspaces, 0
failures.

Guide checklist (§9)

  • R1.2 — no new fetch; the added dispatch reuses the offscreen task path
  • R1.3/R1.4/R1.5 — no lock, no retry loop (one bounded retry, only after a human approved), no accept loop
  • R1.6 — no ack path touched
  • R2.1 — nothing persisted; the preview lives at the agent and the approval is its state
  • R3.7 — matched on the agent's stable extended code, never prose
  • R5.* — a refusal that is not stepUpRequired, an unverifiable approve-request, or a declined prompt all end the disclosure; nothing falls through to present
  • R6.* — the page is told prose or given the presentation; the agent's internal reason stops at the background
  • Deviations — the gatedConsent bypass, explained above and in the code

… at the last hop

`handleDisclose` returned the agent's `stepUpRequired` refusal to the page as
prose, so a `payment.*` or `gov.*` disclosure died after the holder had already
approved it on the consent screen — the ceremony four repositories were built
for was never driven. This is the same failure `vta/request-task.ts` describes
for the consent refusal it handles: the flow discarded at the last hop, where
nobody looks.

The wallet now recognises the refusal, verifies the agent's approve-request,
asks the holder, answers it, and retries `present` with the SAME previewId — the
refusal did not consume it, which is what makes this a retry.

`runPersonaTask` keeps the structured refusal because the wallet must tell one
refusal from another; none of it reaches the page, which still gets prose or the
presentation.

The prompt calls `requestConsent` directly rather than `gatedConsent`: the
latter returns true outright for a remembered origin, which would turn "each
time" into "once per site" — the same failure as binding to the session, from a
different direction. `noRemember` so it cannot become one.

The approve-response is a payload, not a document: it goes as an ordinary Trust
Task and the channel's own `assertionMethod` signature IS the gate. Minted 0.3,
so a bound approval is answered `recorded` and elevates nothing.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…rker

CI caught this before it shipped: `background.js` gained a dynamic `import()`,
which an MV3 service worker cannot load. The cause is structural, not a stray
import — verifying the agent's approve-request resolves a DID, and DID
resolution cannot be statically bundled into a worker.

That is exactly why `doStepUpVta` verifies in the offscreen document and asks
BACK to the background for the prompt. I had mirrored the wrong half. The verify
and the signing now live in the offscreen where they can, and the background
contributes the only thing it uniquely can: a window for the human.

The prompt is its own message rather than a reuse of RUNTIME_STEP_UP_CONSENT,
because that one is answered through `gatedConsent`, which returns true outright
for a remembered origin — right for a login step-up, wrong where the whole
requirement is that the holder decides each time.

The guard is documented in this repo's own CLAUDE.md and I did not run it; only
`npm test` and a build whose output I grepped for the word "error". Running the
job's actual assertions is the check that would have caught it locally.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78

Copy link
Copy Markdown
Contributor Author

CI caught a real architectural mistake, and the fix changes where the work happens.

background.js gained a dynamic import() — the one thing this repo's CI asserts is absent, because an MV3 service worker cannot load one. Not a stray import: verifying the agent's approve-request resolves a DID, and DID resolution cannot be statically bundled into a worker.

That is precisely why doStepUpVta verifies in the offscreen document and asks back to the background for the prompt. I read that precedent and mirrored the wrong half of it. The verify and the signing now live in the offscreen; the background contributes the only thing it uniquely can, a window for the human.

Two things came out of the restructure:

  • The prompt is its own message (RUNTIME_DISCLOSURE_STEP_UP_CONSENT) rather than a reuse of the login one, which is answered through gatedConsent — that returns true outright for a remembered origin, which would turn "each time" into "once per site".
  • background.ts no longer imports the verifier at all, so the worker cannot regain the dependency by accident.

On my side: the guard is documented in this repo's CLAUDE.md and I did not run it. I ran npm test and a build whose output I grepped for the word "error" — the guard is a separate CI step. I have now run it and the other three bundle assertions locally; all pass. Running the job's actual commands is the check that would have caught this before the push.

@stormer78
stormer78 merged commit 72b9255 into main Sep 8, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/disclosure-step-up-ceremony branch September 8, 2026 11:33
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

🔎 A manual security review is recommended before merging. Please contact the Security team for specifics and remediation guidance.

ℹ️ Detailed findings are not published on public repositories; the Security team holds the complete report.

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.

2 participants