feat(persona): finish a release: stepUp disclosure instead of failing at the last hop - #198
Conversation
… 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>
|
CI caught a real architectural mistake, and the fix changes where the work happens.
That is precisely why Two things came out of the restructure:
On my side: the guard is documented in this repo's CLAUDE.md and I did not run it. I ran |
🛡️ AI Agentic Security Code Review🔎 A manual security review is recommended before merging. Please contact the Security team for specifics and remediation guidance.
|
A
release: stepUpdisclosure through the wallet now completes instead offailing 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.tscalls the tasks directly throughrunPersonaTask, andhandleDisclose— preview → consent surface → present — has been complete forsome time, with
disclosure-consent.tsxrenderingbuildConsentViewandpage-task-policy.tskeeping a page off the family.The actual gap was narrower and worse:
handleDisclosedidso a
stepUpRequiredrefusal became a prose error handed to the page. Theholder approved on the consent screen, the agent asked for the fresh
authentication
payment.*andgov.*require, and the ceremony this stackspent four repositories building was never driven.
That is the exact failure
vta/request-task.tswarns about for the consentrefusal 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
runPersonaTaskkeeps the structured refusal. It returnsRelayTaskFailurerather than the page-facing prose shape — the wallet has to tell one refusal
from another (R3.7). None of it reaches the page:
handleDisclosestillreturns prose or the presentation, exactly as
handleRequestTasknarrows thesame members off for a site.
doStepUpVtaalready enforcesfor the RP step-up. Everything the holder reads comes from inside the
agent's signature, and
verifyDisclosureStepUpadditionally refuses a requestwhose signed
previewIdis not the one the refusal named. A refused requestreturns before any prompt, so the holder is never shown a claim list this
wallet could not verify.
previewIdis 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 pointgatedConsentreturnstrueoutright for an origin the holder ticked"remember this site" for. Right for a login step-up; wrong here.
release: stepUpexists to make the holder decide each time, and an origin-level grantanswering 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
requestConsentdirectly, withnoRemember: trueso it cannotbecome one. There is nothing to remember: the approval is bound to a single
previewIdand 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 isprecisely the gate
approve-responserequires — sodisclosureApprovalPayloadreturns a payload, not a document. Building a proof here would duplicate or
overwrite the channel's, which is the reason
provision/integrationis calledout in this repo's guide as the case that must bypass a channel. A test asserts
the payload carries no
proofand notype.It is minted as 0.3, so a bound approval is answered
recordedand elevatesnothing (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.
disclosureStepUpFromexists 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.tshas no unittests 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 -bclean,npm run buildclean, 979 tests across four workspaces, 0failures.
Guide checklist (§9)
stepUpRequired, an unverifiable approve-request, or a declined prompt all end the disclosure; nothing falls through topresentgatedConsentbypass, explained above and in the code