From f6843bcdc7c9fd3dd8558998ba50fcb108edaf3a Mon Sep 17 00:00:00 2001
From: Glenn Gore
Date: Sun, 30 Aug 2026 22:53:48 +0200
Subject: [PATCH] fix(vtc-admin): let the wallet choose the identity, instead
of reading the vault
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
vtc-service/admin-ui/src/lib/wallet.ts | 97 +++++++++++++++++++++++-
vtc-service/admin-ui/src/pages/Login.tsx | 47 +++++++++++-
2 files changed, 138 insertions(+), 6 deletions(-)
diff --git a/vtc-service/admin-ui/src/lib/wallet.ts b/vtc-service/admin-ui/src/lib/wallet.ts
index 2ab4d2f0..68ba4ce3 100644
--- a/vtc-service/admin-ui/src/lib/wallet.ts
+++ b/vtc-service/admin-ui/src/lib/wallet.ts
@@ -84,11 +84,31 @@ interface VtaWalletProvider {
secretKind?: SecretKind;
}): Promise;
proxyLogin?(params: {
- entryId: string;
+ entryId?: string;
nonce?: string;
target?: { kind: string; [k: string]: unknown };
ttlSecondsHint?: number;
}): Promise;
+ /** Which persona this site knows the user as, resolving or binding one.
+ * Mints nothing. Present from the wallet build that added first-use
+ * persona binding (OpenVTC/vta-browser-plugin#145). */
+ walletProfile?(params: {
+ target?: { kind: string; [k: string]: unknown };
+ }): Promise;
+}
+
+interface WalletProfileWireResult {
+ /** The persona DID this VTC knows the operator as. `/auth/challenge` is
+ * bound to it, so it has to be known before a nonce can be asked for. */
+ did: string;
+ /** The vault entry backing it — passed straight to `proxyLogin` so the
+ * wallet does not repeat the lookup it just did. */
+ entryId: string;
+ /** True when the wallet bound this persona just now, i.e. the operator was
+ * prompted. The VTC has never seen it, so the sign-in that follows will be
+ * refused until the DID is on the ACL — which is worth saying plainly
+ * rather than surfacing as an opaque 403. */
+ bound: boolean;
}
declare global {
@@ -115,6 +135,18 @@ export function isWalletProxyAvailable(): boolean {
);
}
+/** True iff the wallet can resolve-or-bind a persona for this origin itself.
+ *
+ * A capability probe, not a compatibility fold: without it the proxy path can
+ * only work for an operator who has already bound an entry by hand, and the
+ * difference is worth an accurate message rather than a `TypeError`. */
+export function isWalletProfileAvailable(): boolean {
+ return (
+ isWalletProxyAvailable() &&
+ typeof window.vtaWallet?.walletProfile === "function"
+ );
+}
+
/** API base for the wallet's auth round-trip. Points at the VTC's
* header-exempt wallet surface, served same-origin with the admin UI. */
function walletApiBase(): string {
@@ -191,6 +223,63 @@ export async function loginWithWalletProxy(
"Chosen entry has no principal DID — only did-self-issued entries can proxy-login.",
);
}
+ return runProxySiop(entry.principalDid, entry.id);
+}
+
+/**
+ * The preferred VTA-proxied sign-in: let the wallet say which persona this
+ * VTC knows the operator as, then run the round-trip as that persona.
+ *
+ * Why this and not `listProxyCandidates()` first — the flow it replaces asked
+ * the wallet to enumerate *every* vault entry pinned to this VTC in order to
+ * find one, which is a disclosure of the operator's vault to answer a question
+ * about a single entry, and on a fresh wallet it returned nothing and dead-ended
+ * with "add an entry, then retry". The wallet now owns that question: it
+ * resolves the entry for this origin, or asks the operator to choose a persona
+ * and remembers the answer.
+ *
+ * The persona DID has to be known *before* the challenge, because
+ * `/auth/challenge` is bound to it — which is why this cannot be folded into
+ * `proxyLogin` as a single call.
+ */
+export async function loginWithWalletProfile(): Promise {
+ if (!isWalletProfileAvailable()) {
+ throw new Error(
+ "This VTA wallet build cannot choose an identity for a site. " +
+ "Update the extension, or pin a did-self-issued vault entry to this VTC by hand.",
+ );
+ }
+ const rp = await rpDid();
+ const profile = await window.vtaWallet!.walletProfile!({
+ target: { kind: "did", did: rp },
+ });
+ if (!profile.did || !profile.entryId) {
+ throw new Error("wallet returned no identity for this site");
+ }
+ try {
+ return await runProxySiop(profile.did, profile.entryId);
+ } catch (err) {
+ if (!profile.bound) throw err;
+ // The persona was created a moment ago, so this VTC has never seen it and
+ // the ACL gate in `handle_challenge` is by far the likeliest cause. Say
+ // which DID needs admitting: the operator cannot act on a 403 alone, and
+ // the DID is not otherwise on screen anywhere.
+ const message = err instanceof Error ? err.message : String(err);
+ throw new Error(
+ `${message}\n\nThis was the first sign-in as ${profile.did}. ` +
+ "If the VTC refused it, that DID needs an Admin entry in this VTC's ACL — " +
+ `ask another admin to run \`vtc admin invite --did ${profile.did}\`.`,
+ );
+ }
+}
+
+/** Challenge → mint → authenticate, as a known persona. Shared by the
+ * wallet-resolved path and the hand-picked-entry path, so both apply the same
+ * rule and the same error handling. */
+async function runProxySiop(
+ principalDid: string,
+ entryId: string,
+): Promise {
const rp = await rpDid();
const base = walletApiBase().replace(/\/+$/, "");
@@ -199,7 +288,7 @@ export async function loginWithWalletProxy(
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
- body: JSON.stringify({ did: entry.principalDid }),
+ body: JSON.stringify({ did: principalDid }),
});
if (!chRes.ok) {
// Carry the daemon's message, not just the code. A 403 here is the ACL
@@ -221,7 +310,7 @@ export async function loginWithWalletProxy(
// 2. VTA mints the SIOP id_token (long-term key stays in the VTA).
const pl = await window.vtaWallet!.proxyLogin!({
- entryId: entry.id,
+ entryId,
nonce: ch.challenge,
target: { kind: "did", did: rp },
});
@@ -259,6 +348,6 @@ export async function loginWithWalletProxy(
accessToken: tokenResp.tokens.accessToken,
refreshToken: tokenResp.tokens.refreshToken ?? "",
sessionId: tokenResp.session.id,
- holderDid: entry.principalDid,
+ holderDid: principalDid,
};
}
diff --git a/vtc-service/admin-ui/src/pages/Login.tsx b/vtc-service/admin-ui/src/pages/Login.tsx
index 0c6f558e..ad76ef97 100644
--- a/vtc-service/admin-ui/src/pages/Login.tsx
+++ b/vtc-service/admin-ui/src/pages/Login.tsx
@@ -22,9 +22,11 @@ import {
} from "@/lib/webauthn";
import {
isWalletAvailable,
+ isWalletProfileAvailable,
isWalletProxyAvailable,
listProxyCandidates,
loginWithWallet,
+ loginWithWalletProfile,
loginWithWalletProxy,
type ProxyVaultEntry,
} from "@/lib/wallet";
@@ -52,6 +54,7 @@ export function Login() {
const walletAvailable = isWalletAvailable();
const proxyAvailable = isWalletProxyAvailable();
+ const profileAvailable = isWalletProfileAvailable();
const busy = phase.kind === "running" || walletPhase.kind === "running";
// Shared success tail: the wallet returned a bearer; mirror it into the
@@ -178,7 +181,29 @@ export function Login() {
}
};
+ // The wallet owns "which identity does this VTC know me as". It resolves the
+ // entry for this origin, or — on a first sign-in — asks the operator to pick
+ // a persona and remembers it. The flow this replaces asked the wallet to
+ // enumerate every entry pinned to this VTC just to find one, and on a fresh
+ // wallet returned nothing and dead-ended with "add an entry, then retry".
const handleProxyStart = async () => {
+ setWalletPhase({ kind: "running" });
+ setCandidates(null);
+ try {
+ const result = await loginWithWalletProfile();
+ await finishWithBearer(result.accessToken);
+ } catch (err) {
+ const e = err as { message?: string };
+ setWalletPhase({ kind: "error", message: e.message ?? String(err) });
+ }
+ };
+
+ // Escape hatch, not the default: pick from the entries pinned to this VTC.
+ // Kept because an operator may hold more than one persona here — an Admin and
+ // a member identity, say — and the wallet's own answer is the one bound to
+ // this origin. It stays behind an explicit click because reaching it costs a
+ // consent prompt that enumerates the vault to this page.
+ const handleChooseIdentity = async () => {
setWalletPhase({ kind: "running" });
setCandidates(null);
try {
@@ -187,7 +212,7 @@ export function Login() {
setWalletPhase({
kind: "error",
message: "No did-self-issued vault entry is pinned to this VTC.",
- hint: "Open the wallet, add an entry targeting this VTC's DID, then retry.",
+ hint: "Use “Sign in via VTA-proxied SIOP” instead — the wallet will ask which identity to use and remember it.",
});
return;
}
@@ -242,7 +267,7 @@ export function Login() {