Skip to content

feat(rp-login): add walletProfile — ask who this site knows you as - #145

Merged
stormer78 merged 1 commit into
mainfrom
feat/wallet-profile-resolution
Aug 30, 2026
Merged

feat(rp-login): add walletProfile — ask who this site knows you as#145
stormer78 merged 1 commit into
mainfrom
feat/wallet-profile-resolution

Conversation

@stormer78

@stormer78 stormer78 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

#144 has merged; this is rebased onto main and stands alone.

The RP side has already landedOpenVTC/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-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. Both pages get it today from vaultList() — 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

const { did, entryId, bound } = await wallet.walletProfile({ target });

Resolves the entry for the browser-attested origin, or raises the first-use picker and binds the answer. 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.

VTA round trips Prompts (untrusted origin) Vault disclosed
vaultList + proxyLogin 2 2 every matching entry
walletProfile + proxyLogin 2 1 one entry

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. A prompt there asks the operator to re-approve a decision they already made, which is how prompts stop being read.

principalDid is 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.ts inlined a second copy of the BridgeMethod union. 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.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 origin pin in this wallet is keyed on origin.
  • A mistyped constant routes to no handler; sendMessage resolves undefined and the page sees a shapeless failure with nothing pointing back.

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 — 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 lint clean (tsc -b, not --noEmit)
  • npm run build clean
  • npm test — 660 tests, 0 failures (4 new)
  • MV3 invariants: dist/background.js single bundle, no dynamic import(), no chrome.cookies, no static content_scripts, no cookies permission
  • Module boundaries + entry points unchanged (packages/core untouched)
  • R1.2 — no new outbound fetch; reuses existing offscreen handlers
  • R3.7 — no matching on message text
  • RUNTIME_WALLET_PROFILE is in PAGE_FACING_RUNTIME_TYPES, so its origin comes from the browser — now enforced by a test rather than by a comment
  • No compatibility fold — new method, nothing widened or dual-accepted

Base automatically changed from feat/first-use-persona-binding to main August 30, 2026 20:56
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
stormer78 force-pushed the feat/wallet-profile-resolution branch from b8930d6 to db71e6d Compare August 30, 2026 21:06
@stormer78
stormer78 merged commit b1890f6 into main Aug 30, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/wallet-profile-resolution branch August 30, 2026 21:07
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>
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