feat(rp-login): add walletProfile — ask who this site knows you as - #145
Merged
Conversation
stormer78
added a commit
to affinidi/affinidi-webvh-service
that referenced
this pull request
Aug 30, 2026
…eading the vault (#177) Proxy sign-in began by asking the wallet to enumerate every vault entry pinned to this RP in order to find one. That is a disclosure of the operator's vault to answer a question about a single entry — and it costs its own consent prompt to make. On a wallet with nothing pinned here it returned nothing, and the page gave up with "No did-self-issued vault entry is pinned to this RP. Open the wallet, add an entry with this RP's DID as a target, then try again": a setup step in another application, quoted at someone who was trying to log in. `walletProfile({ target })` resolves the entry bound to this origin, or asks the operator which identity to use and remembers it, and returns the persona DID with the entry id. It mints nothing and issues no session. The DID must be known before `/auth/challenge`, which binds the nonce to it, so this is two calls rather than one. `resolveProxyEntry()` returns a `ProxyVaultEntry`, so it drops into the existing `loginWithWalletProxy` and **the flow visualization is unchanged** — the demo's point is showing the round-trip, and the round-trip did not move. Only the way the entry is found did. The synthesised entry carries just the two fields that path reads; label and context are not invented, because this is the wallet's answer about one entry rather than a vault listing. A first sign-in names the DID that needs admitting. The persona was created a moment ago, so the RP has never seen it and will refuse — a bare refusal an operator cannot act on. `bound: true` tells us it was the first time. "Login as a different identity…" keeps the old enumeration for an operator holding more than one persona here, behind an explicit click. It is also the only proxy route on a wallet build that predates `walletProfile`, which is what `isWalletProfileAvailable()` distinguishes — a capability probe producing an accurate message, not a compatibility fold. Requires OpenVTC/vta-browser-plugin#145; `proxyLogin` and `vaultList` are untouched there, so this can land in either order. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
added a commit
to OpenVTC/verifiable-trust-infrastructure
that referenced
this pull request
Aug 30, 2026
…g the vault (#1212) Proxy sign-in began by asking the wallet to enumerate every vault entry pinned to this VTC, so it could find one: vaultList({ targetDid, secretKind: "didSelfIssued" }) Two problems, and the second is the one operators hit. It is a disclosure of the operator's vault to answer a question about a single entry — and it costs its own consent prompt to make. And on a wallet with nothing pinned here it returns an empty array, at which point the page gave up with "No did-self-issued vault entry is pinned to this VTC. Open the wallet, add an entry targeting this VTC's DID, then retry" — a setup step in another application, quoted at someone who was trying to sign in. The wallet now owns that question. `walletProfile({ target })` resolves the entry bound to this origin, or asks the operator which identity to use and remembers the answer, and returns the persona DID with the entry id. It mints nothing. const { did, entryId } = await walletProfile({ target }); const { challenge } = await POST /auth/challenge { did }; await proxyLogin({ entryId, nonce: challenge, target }); The DID has to be known before the challenge, because `handle_challenge` binds the nonce to it — which is why this is two calls and not one, and why `proxyLogin` alone could never have reached it. Round trips are unchanged: the profile call replaces the vault listing one for one, and passing `entryId` back means `proxyLogin` does not repeat the lookup. One fewer consent prompt in the steady state. **A first sign-in now says which DID needs admitting.** The persona was created a moment ago, so this VTC has never seen it and `handle_challenge` refuses on the ACL gate — a 403 an operator cannot act on. `bound: true` tells us this was the first time, so the message names the DID and the `vtc admin invite --did …` that fixes it. **Choosing among several identities is kept, as an explicit action.** An operator may hold both an Admin and a member persona here, and the wallet returns the one bound to this origin. "Sign in as a different identity…" still runs the old enumeration — behind a click, because reaching it discloses the vault to this page. It is also the only proxy route on a wallet build that predates `walletProfile`, which is what `isWalletProfileAvailable()` distinguishes: a capability probe that produces an accurate message, not a compatibility fold. `loginWithWalletProxy` now delegates to a shared `runProxySiop`, so the wallet-resolved and hand-picked paths apply exactly the same rule. Requires OpenVTC/vta-browser-plugin#145. `proxyLogin` and `vaultList` are untouched there, so this can land in either order — the button degrades to the picker until the wallet ships. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
#144 made `proxyLogin({})` resolve-or-bind, and that is enough for an RP that only needs a token. It is not enough for the two RPs we actually have. `vtc-service/admin-ui` and `did-hosting-ui` both bind their `/auth/challenge` to the persona DID: POST /auth/challenge { did: entry.principalDid } proxyLogin({ entryId, nonce: challenge }) The DID is needed *before* the nonce, so it cannot come out of `proxyLogin` — and both pages get it today from `vaultList()`, which enumerates the user's whole vault to answer a question about one entry. On a site with nothing bound that returns empty and the page gives up, which is exactly what #144 was meant to stop and could not reach. `walletProfile({ target })` answers that question and only that one. It resolves the entry for the browser-attested origin, or raises the first-use picker and binds the answer, and returns `{ did, entryId, bound }`. It mints nothing and issues no session. **`entryId` comes back deliberately.** The objection to a page holding one was never possession; it was that *learning* one cost a vault-enumerating prompt. An id handed back for the site's own entry costs nothing, and passing it to `proxyLogin` saves the lookup this call just did — so the two-call flow is the same number of VTA round trips as the `vaultList` + `proxyLogin` it replaces, with one fewer prompt and no disclosure of the rest of the vault. **Two prompts on a first sign-in, one after.** Binding raises the picker; the sign-in raises its own consent. Folding the second into the first would mean a call that mints nothing silently pre-authorizing one that does. First contact with a site is the place to ask twice. An already-bound lookup does not prompt at all: it discloses one DID, to the site that DID exists for, which is about to receive it inside an id_token anyway. `principalDid` is read back from the VTA rather than remembered, since it is maintainer-derived and an entry whose secret was rotated there would otherwise report a DID it no longer signs as. Two drift traps found on the way, both now guarded: - `provider.ts` inlined a second copy of the `BridgeMethod` union, so adding a method failed at its own call site with an error naming every method but the new one. It uses the type now. - `content.ts` cannot `import` (classic script), so it inlines the protocol constants under a "keep these in sync" comment with nothing enforcing it. Two invariants ride on that hand-sync: a method routed to a type absent from `PAGE_FACING_RUNTIME_TYPES` takes its origin from the page's own message body rather than the browser — and every vault entry, trust record and pin here is keyed on origin — while a mistyped constant routes to no handler and surfaces as a shapeless failure. `tests/page-facing-surface.test.mts` reads both sources and checks them against each other; its first assertions guard against the regexes silently matching nothing. Requires the RP-side change in OpenVTC/verifiable-trust-infrastructure and OpenVTC/affinidi-webvh-service; neither wallet call is removed, so the repos can land in either order. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
force-pushed
the
feat/wallet-profile-resolution
branch
from
August 30, 2026 21:06
b8930d6 to
db71e6d
Compare
stormer78
added a commit
to affinidi/affinidi-webvh-service
that referenced
this pull request
Aug 30, 2026
`isWalletProfileAvailable()` existed so #177 could merge before the wallet method it depends on did. That has now shipped (OpenVTC/vta-browser-plugin#145), so the second probe describes a wallet build that does not exist: every extension that exposes `proxyLogin` exposes `walletProfile` too. Folded into `isWalletProxyAvailable()`, which now checks the three methods this screen actually calls. The proxy button is no longer conditionally hidden, and the secondary button no longer changes its label to stand in as the primary route — both were arms for a wallet nobody has. Presence detection stays and is not the same thing: the extension may simply not be installed, which is why the buttons are gated at all. Nothing about the flow changes. `resolveProxyEntry` → `loginWithWalletProxy` is untouched, the flow visualization with it, and "Login as a different identity…" keeps the entry picker for an operator holding more than one persona here. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
added a commit
to OpenVTC/verifiable-trust-infrastructure
that referenced
this pull request
Aug 30, 2026
`isWalletProfileAvailable()` existed so #1212 could merge before the wallet method it depends on did. That has now shipped (OpenVTC/vta-browser-plugin#145), so the second probe describes a wallet build that does not exist: every extension that exposes `proxyLogin` exposes `walletProfile` too. Folded into `isWalletProxyAvailable()`, which now checks the three methods this page actually calls. The proxy button is no longer conditionally hidden, and the secondary button no longer changes its label to stand in as the primary route — both were arms for a wallet nobody has. Presence detection stays and is not the same thing: the extension may simply not be installed, which is why the buttons are gated at all. Nothing about the flow changes. `walletProfile` → `/auth/challenge` → `proxyLogin` is untouched, and "Sign in as a different identity…" keeps the entry picker for an operator holding more than one persona here. 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.
#144 has merged; this is rebased onto
mainand stands alone.The RP side has already landed — OpenVTC/verifiable-trust-infrastructure#1212 and affinidi/affinidi-webvh-service#177 are merged. Both degrade to the entry picker until this ships, so nothing is broken in the meantime, but the dead end they were fixing stays until it does.
Why #144 was not enough
#144 made
proxyLogin({})resolve-or-bind, which is enough for an RP that only needs a token. It is not enough for the two RPs we actually have.vtc-service/admin-uianddid-hosting-uiboth bind their/auth/challengeto the persona DID:The DID is needed before the nonce, so it cannot come out of
proxyLogin. Both pages get it today fromvaultList()— which enumerates the user's whole vault, behind its own consent prompt, to answer a question about one entry. On a site with nothing bound it returns empty and the page gives up. That is the dead end #144 was meant to remove and could not reach.The call
Resolves the entry for the browser-attested origin, or raises the first-use picker and binds the answer. Mints nothing and issues no session.
entryIdcomes back deliberatelyThe objection to a page holding one was never possession — it was that learning one cost a vault-enumerating prompt. An id handed back for the site's own entry costs nothing, and passing it to
proxyLoginsaves the lookup this call just did.vaultList+proxyLoginwalletProfile+proxyLoginTwo prompts on a first sign-in, one after
Binding raises the picker; the sign-in raises its own consent. Folding the second into the first would mean a call that mints nothing silently pre-authorizing one that does. First contact with a site is the place to ask twice.
An already-bound lookup does not prompt at all — it discloses one DID, to the site that DID exists for, which is about to receive it inside an id_token anyway. A prompt there asks the operator to re-approve a decision they already made, which is how prompts stop being read.
principalDidis read back from the VTA rather than remembered: it is maintainer-derived, and an entry whose secret was rotated there would otherwise report a DID it no longer signs as.Two drift traps found on the way, both now guarded
provider.tsinlined a second copy of theBridgeMethodunion. Adding a method failed at its own call site with an error listing every method but the new one. It uses the type now.content.tscannotimport(classic script), so it inlines the protocol constants under a "keep these in sync" comment with nothing enforcing it. Two invariants ride on that hand-sync:PAGE_FACING_RUNTIME_TYPEStakes its origin from the page's own message body rather than the browser — and every vault entry, trust record and origin pin in this wallet is keyed on origin.sendMessageresolvesundefinedand the page sees a shapeless failure with nothing pointing back.tests/page-facing-surface.test.mtsreads both sources and checks them against each other. Its first assertions guard against the regexes silently matching nothing — verified by mutating a constant and confirming it fails.RP side — merged
Neither wallet call is removed, so the repos could land in any order; they went first.
Pre-merge checklist
npm run lintclean (tsc -b, not--noEmit)npm run buildcleannpm test— 660 tests, 0 failures (4 new)dist/background.jssingle bundle, no dynamicimport(), nochrome.cookies, no staticcontent_scripts, nocookiespermissionpackages/coreuntouched)RUNTIME_WALLET_PROFILEis inPAGE_FACING_RUNTIME_TYPES, so its origin comes from the browser — now enforced by a test rather than by a comment