Skip to content

Commit db71e6d

Browse files
committed
feat(rp-login): add walletProfile — ask who this site knows you as
#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>
1 parent 889aa68 commit db71e6d

6 files changed

Lines changed: 321 additions & 21 deletions

File tree

packages/demo-rp/login-harness.mjs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ way a relying party would. Keep the wallet's offscreen console open beside this.
6565
<button id="didcomm">Login over DIDComm</button>
6666
<button id="siop">Login over REST (SIOP)</button>
6767
<button id="proxy">Login as a per-site persona (proxy SIOP)</button>
68+
<button id="profile">Which identity does this site know me as?</button>
6869
6970
<p class="muted">The proxy button names no vault entry, which is the point: the wallet resolves
7071
one from this origin, and on a first visit asks which identity to use and binds the answer. A
@@ -112,6 +113,25 @@ second click should not prompt for an identity again.</p>
112113
}
113114
});
114115
116+
// The shape an RP uses when its challenge is bound to the persona DID: ask
117+
// the wallet who this site knows you as, THEN fetch a challenge for that DID,
118+
// then mint. Here it just reports the answer — the harness has no challenge
119+
// endpoint of its own — which is enough to see the first-use prompt fire and
120+
// to confirm a second click does not prompt again.
121+
document.getElementById("profile").addEventListener("click", async () => {
122+
if (!probe()) return;
123+
show(
124+
"Asking the wallet…",
125+
"On a first visit it should ask which identity to bind to this site.",
126+
);
127+
try {
128+
const r = await window.vtaWallet.walletProfile({});
129+
show(r.bound ? "walletProfile bound a new identity" : "walletProfile resolved", r);
130+
} catch (e) {
131+
show("walletProfile rejected", String(e && e.message ? e.message : e));
132+
}
133+
});
134+
115135
// Deliberately passes NO entryId. A page that supplies one has had to call
116136
// vaultList() to learn it — a second consent prompt that enumerates the
117137
// user's vault to this site. Omitting it is the shape a relying party should

packages/extension/src/background.ts

Lines changed: 112 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import {
6767
RUNTIME_VAULT_LIST_PAGE,
6868
RUNTIME_VAULT_PROXY_LOGIN,
6969
RUNTIME_VAULT_PROXY_LOGIN_PAGE,
70+
RUNTIME_WALLET_PROFILE,
7071
RUNTIME_VAULT_RELEASE,
7172
RUNTIME_VAULT_UPSERT,
7273
OFFSCREEN_LOCK_WALLET,
@@ -159,6 +160,8 @@ import {
159160
type RuntimeVaultReleaseRequest,
160161
type RuntimeVaultReleaseResponse,
161162
type ProxyLoginParams,
163+
type RuntimeWalletProfileRequest,
164+
type RuntimeWalletProfileResponse,
162165
type RuntimeVaultUpsertRequest,
163166
type RuntimeVaultUpsertResponse,
164167
type RuntimeApproverStateResponse,
@@ -1839,7 +1842,7 @@ async function handleVaultProxyLoginPage(
18391842
// Resolve the entry BEFORE prompting. Which prompt to raise depends on
18401843
// whether this site already has a persona bound, and a page that calls this
18411844
// with no VTA connected should fail without raising one at all.
1842-
const resolved = await resolveProfileEntry(req);
1845+
const resolved = await resolveProfileEntry(req.origin, req.params.entryId);
18431846
if (!resolved.ok) return { ok: false, error: resolved.error };
18441847

18451848
if (resolved.entryId) {
@@ -1898,6 +1901,95 @@ async function handleVaultProxyLoginPage(
18981901
return result;
18991902
}
19001903

1904+
/**
1905+
* Which persona this site knows the user as — resolve, or bind one.
1906+
*
1907+
* Split out of the sign-in because an RP whose `/auth/challenge` is bound to
1908+
* the persona DID needs that DID *before* it can ask for a nonce, and so cannot
1909+
* reach it through `proxyLogin` at all. The route it had was `vaultList()`,
1910+
* which discloses every entry to answer a question about one.
1911+
*
1912+
* ## Two prompts on a first sign-in, and why that is the right number
1913+
*
1914+
* A page that binds and then signs in raises the picker here and the sign-in
1915+
* consent in `handleVaultProxyLoginPage` — two decisions the first time, one
1916+
* every time after. Folding the second into the first would mean this call,
1917+
* which mints nothing and issues no session, silently pre-authorizing one that
1918+
* does. First contact with a site is the place to ask twice; every later
1919+
* sign-in is a single prompt, and the operator can still tick "remember".
1920+
*
1921+
* Nothing is minted here, and no session is issued. The result is a DID the
1922+
* site is about to be told anyway, and the id of the entry holding it.
1923+
*/
1924+
async function handleWalletProfile(
1925+
req: RuntimeWalletProfileRequest,
1926+
): Promise<RuntimeWalletProfileResponse> {
1927+
const target = req.params.target as { kind?: string; did?: string } | undefined;
1928+
const targetDid = target?.kind === "did" ? target.did : undefined;
1929+
1930+
const resolved = await resolveProfileEntry(req.origin);
1931+
if (!resolved.ok) return { ok: false, error: resolved.error };
1932+
1933+
if (resolved.entryId) {
1934+
// Already bound. No prompt: this discloses one DID, to the site that DID
1935+
// exists for, which is about to receive it inside an id_token anyway. A
1936+
// prompt here would be asking the operator to re-approve a decision they
1937+
// already made, which is how prompts stop being read.
1938+
const did = await principalDidFor(req.origin, resolved.entryId);
1939+
if (!did.ok) return { ok: false, error: did.error };
1940+
return { ok: true, result: { did: did.did, entryId: resolved.entryId, bound: false } };
1941+
}
1942+
1943+
// `requestConsent`, not `gatedConsent` — see handleVaultProxyLoginPage. A
1944+
// remembered origin has consented to being signed in as an identity already
1945+
// chosen, never to a new one being chosen for it.
1946+
const decision = await requestConsent({
1947+
origin: req.origin,
1948+
action: "Choose the identity this site knows you as",
1949+
chooseProfile: true,
1950+
...(targetDid ? { rpDid: targetDid } : {}),
1951+
});
1952+
if (!decision.approved || !decision.selectedDid) {
1953+
return { ok: false, error: "identity selection denied by user" };
1954+
}
1955+
1956+
const bound = await bindProfileEntry(req.origin, decision.selectedDid, targetDid);
1957+
if (!bound.ok) return { ok: false, error: bound.error };
1958+
if (decision.remember) await trustOrigin(req.origin, targetDid);
1959+
1960+
return {
1961+
ok: true,
1962+
result: { did: decision.selectedDid, entryId: bound.entryId, bound: true },
1963+
};
1964+
}
1965+
1966+
/**
1967+
* The persona DID an already-bound entry acts as.
1968+
*
1969+
* `principalDid` is maintainer-derived, so it is read back from the VTA rather
1970+
* than reconstructed here: the wallet seals the secret and never sees it again,
1971+
* and an entry whose secret was rotated at the VTA would otherwise report a DID
1972+
* it no longer signs as.
1973+
*/
1974+
async function principalDidFor(
1975+
origin: string,
1976+
entryId: string,
1977+
): Promise<{ ok: true; did: string } | { ok: false; error: string }> {
1978+
const listed = await handleVaultList({
1979+
type: RUNTIME_VAULT_LIST,
1980+
filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin },
1981+
});
1982+
if (!listed.ok) return { ok: false, error: listed.error };
1983+
const entry = listed.result.entries.find((e) => e.id === entryId);
1984+
if (!entry?.principalDid) {
1985+
// An entry with no principalDid cannot mint an id_token, so returning it
1986+
// would hand the page a DID-shaped hole that fails at `/auth/challenge`
1987+
// with nothing pointing back here.
1988+
return { ok: false, error: `vault entry ${entryId} names no persona DID` };
1989+
}
1990+
return { ok: true, did: entry.principalDid };
1991+
}
1992+
19011993
/** Send a resolved proxy-login to the offscreen document, where the holder
19021994
* identity and the DIDComm unpacking live. */
19031995
async function dispatchProxyLogin(
@@ -1918,30 +2010,32 @@ async function dispatchProxyLogin(
19182010
/**
19192011
* Which vault entry a page-initiated proxy login should use.
19202012
*
1921-
* `entryId` supplied by the page is honoured as-is — that is the pre-existing
1922-
* contract, and a page that discovered an id through `vaultList()` has already
1923-
* had its own consent prompt for it. Otherwise the entry comes from the origin
1924-
* the *browser* attested, never from anything the page said about itself.
2013+
* `suppliedEntryId` is honoured as-is — that is the pre-existing contract, and
2014+
* an id the page holds came either from `vaultList()` (which had its own
2015+
* consent prompt) or from `walletProfile()` (which handed back this site's own
2016+
* entry). Otherwise the entry comes from the origin the *browser* attested,
2017+
* never from anything the page said about itself.
19252018
*
19262019
* `entryId: undefined` with `ok: true` means "this site has no persona yet",
19272020
* which is a first-use prompt, not an error.
19282021
*/
19292022
async function resolveProfileEntry(
1930-
req: RuntimeVaultProxyLoginPageRequest,
2023+
origin: string,
2024+
suppliedEntryId?: string,
19312025
): Promise<{ ok: true; entryId?: string } | { ok: false; error: string }> {
1932-
if (req.params.entryId) return { ok: true, entryId: req.params.entryId };
2026+
if (suppliedEntryId) return { ok: true, entryId: suppliedEntryId };
19332027

19342028
const listed = await handleVaultList({
19352029
type: RUNTIME_VAULT_LIST,
19362030
// `targetOriginPrefix` narrows the set the VTA sends back; it does NOT
19372031
// decide the answer. A prefix is not an origin — `https://example.com` is a
19382032
// prefix of `https://example.com.evil.test` — so `matchProfileEntry` does
19392033
// the actual match locally, with `===`, on the attested origin.
1940-
filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin },
2034+
filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin },
19412035
});
19422036
if (!listed.ok) return { ok: false, error: listed.error };
19432037

1944-
const match = matchProfileEntry(listed.result.entries, req.origin);
2038+
const match = matchProfileEntry(listed.result.entries, origin);
19452039
return { ok: true, ...(match ? { entryId: match.id } : {}) };
19462040
}
19472041

@@ -2329,6 +2423,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
23292423
return true;
23302424
}
23312425

2426+
if ((message as { type?: string })?.type === RUNTIME_WALLET_PROFILE) {
2427+
handleWalletProfile(message as RuntimeWalletProfileRequest)
2428+
.then(sendResponse)
2429+
.catch((e: unknown) =>
2430+
sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }),
2431+
);
2432+
return true; // async sendResponse
2433+
}
2434+
23322435
if ((message as { type?: string })?.type === RUNTIME_VAULT_PROXY_LOGIN_PAGE) {
23332436
handleVaultProxyLoginPage(message as RuntimeVaultProxyLoginPageRequest)
23342437
.then(sendResponse)

packages/extension/src/bridge-protocol.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export type BridgeMethod =
3030
| "walletDefaults"
3131
| "signTrustTask"
3232
| "proxyLogin"
33+
| "walletProfile"
3334
| "vaultList"
3435
| "requestTask";
3536

@@ -266,6 +267,16 @@ export const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task" as const;
266267
* attested origin, so the wallet never attests to a document the page wrote. */
267268
export const RUNTIME_REQUEST_TASK = "vta-wallet/request-task" as const;
268269

270+
/** page → background: which persona this site knows the user as.
271+
*
272+
* Resolve-or-bind, and it mints nothing. An RP whose challenge is bound to the
273+
* persona DID — the shape both `did-hosting` and `vtc-service` use — needs the
274+
* DID *before* it can ask for a nonce, so it cannot get there through
275+
* `proxyLogin` alone. The alternative it had was `vaultList()`, which
276+
* enumerates the user's whole vault to the site to answer a question about one
277+
* entry. This answers that question and only that one. */
278+
export const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile" as const;
279+
269280
export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const;
270281
/** offscreen → background: an inbound, executor-signed `task-consent/request`
271282
* needs a human. Unlike the generic login consent prompt, the surface renders
@@ -1246,6 +1257,42 @@ export interface ProxyLoginParams {
12461257
ttlSecondsHint?: number;
12471258
}
12481259

1260+
/** Page-world params for `window.vtaWallet.walletProfile(...)`. */
1261+
export interface WalletProfileParams {
1262+
/** The relying party this is for, when the page has a DID for itself. Bound
1263+
* as a second target on a newly created entry so the RP's own
1264+
* `vaultList({ targetDid })` finds it; never used to *match* an entry, since
1265+
* only the origin is browser-attested. */
1266+
target?: VaultEntryView["targets"][number];
1267+
}
1268+
1269+
export interface WalletProfileResult {
1270+
/** The persona DID this site knows the user as — the `iss`/`sub` of any SIOP
1271+
* id_token minted for it, and the DID an RP binds its challenge to. */
1272+
did: string;
1273+
/** The vault entry backing it. Pass straight to `proxyLogin` so it does not
1274+
* repeat the lookup this call just did. Naming an entry the wallet handed
1275+
* back for this site costs nothing — the disclosure this avoids was
1276+
* `vaultList()` returning every *other* entry too. */
1277+
entryId: string;
1278+
/** True when this call bound the persona rather than finding one already
1279+
* bound, i.e. the operator was prompted. A page can use it to explain why
1280+
* the sign-in that follows may be refused until the DID is on its ACL. */
1281+
bound: boolean;
1282+
}
1283+
1284+
export type RuntimeWalletProfileResponse =
1285+
| { ok: true; result: WalletProfileResult }
1286+
| { ok: false; error: string };
1287+
1288+
export interface RuntimeWalletProfileRequest {
1289+
type: typeof RUNTIME_WALLET_PROFILE;
1290+
params: WalletProfileParams;
1291+
/** Origin of the calling page, captured by the content script. The entry is
1292+
* resolved and bound against this, never against anything the page says. */
1293+
origin: string;
1294+
}
1295+
12491296
export interface RuntimeVaultProxyLoginPageRequest {
12501297
type: typeof RUNTIME_VAULT_PROXY_LOGIN_PAGE;
12511298
params: ProxyLoginParams;
@@ -1685,6 +1732,7 @@ export const PAGE_FACING_RUNTIME_TYPES = [
16851732
RUNTIME_WALLET_DEFAULTS,
16861733
RUNTIME_SIGN_TRUST_TASK,
16871734
RUNTIME_VAULT_PROXY_LOGIN_PAGE,
1735+
RUNTIME_WALLET_PROFILE,
16881736
RUNTIME_VAULT_LIST_PAGE,
16891737
RUNTIME_REQUEST_TASK,
16901738
] as const;

packages/extension/src/content.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const RUNTIME_MEDIATOR_STATUS = "vta-wallet/mediator-status";
2828
const RUNTIME_WALLET_DEFAULTS = "vta-wallet/wallet-defaults";
2929
const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task";
3030
const RUNTIME_VAULT_PROXY_LOGIN_PAGE = "vta-wallet/vault-proxy-login-page";
31+
const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile";
3132
const RUNTIME_VAULT_LIST_PAGE = "vta-wallet/vault-list-page";
3233
const RUNTIME_REQUEST_TASK = "vta-wallet/request-task";
3334
const RUNTIME_BROADCAST_EVENT = "vta-wallet/broadcast-event";
@@ -61,6 +62,7 @@ const RUNTIME_TYPE_BY_METHOD: Record<BridgeMethod, string> = {
6162
walletDefaults: RUNTIME_WALLET_DEFAULTS,
6263
signTrustTask: RUNTIME_SIGN_TRUST_TASK,
6364
proxyLogin: RUNTIME_VAULT_PROXY_LOGIN_PAGE,
65+
walletProfile: RUNTIME_WALLET_PROFILE,
6466
vaultList: RUNTIME_VAULT_LIST_PAGE,
6567
requestTask: RUNTIME_REQUEST_TASK,
6668
};

packages/extension/src/provider.ts

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
// `window.postMessage` using the bridge protocol.
55

66
import type {
7+
BridgeMethod,
78
RequestTaskParams,
89
ApiGetParams,
910
ApiGetResult,
@@ -21,6 +22,8 @@ import type {
2122
VaultListResultView,
2223
VaultProxyLoginResultView,
2324
WalletDefaultsResult,
25+
WalletProfileParams,
26+
WalletProfileResult,
2427
} from "./bridge-protocol.js";
2528

2629
// Bundled as a standalone page-world script, so it must be self-contained
@@ -87,6 +90,24 @@ interface VtaWallet {
8790
* to learn one, which costs a second consent prompt and shows this site the
8891
* rest of the user's vault. */
8992
proxyLogin(params: ProxyLoginParams): Promise<VaultProxyLoginResultView>;
93+
/** Which persona this site knows the user as, resolving or binding one.
94+
*
95+
* Mints nothing and issues no session — it answers a question. On a site
96+
* with no persona bound the user is asked to pick one and it is remembered;
97+
* after that this is a lookup.
98+
*
99+
* For an RP whose `/auth/challenge` is bound to the persona DID, this is the
100+
* first call: the DID it returns is what the challenge is requested for, and
101+
* the `entryId` goes straight into `proxyLogin` so the wallet does not
102+
* repeat the lookup.
103+
*
104+
* const { did, entryId } = await wallet.walletProfile({ target });
105+
* const { challenge } = await postChallenge(did);
106+
* await wallet.proxyLogin({ entryId, nonce: challenge, target });
107+
*
108+
* `bound: true` means the persona was just created, so the relying party has
109+
* never seen it and may refuse the sign-in until it is on its access list. */
110+
walletProfile(params: WalletProfileParams): Promise<WalletProfileResult>;
90111
/** Enumerate vault entries (metadata only, no secret material) via
91112
* vault/list/0.1. Each returned entry's `principalDid` is the DID the entry
92113
* would act AS when used in a proxy-login call.
@@ -140,19 +161,12 @@ window.addEventListener("message", (event: MessageEvent) => {
140161
else entry.reject(new Error(data.error));
141162
});
142163

164+
// `BridgeMethod`, not a copy of it. This used to inline the same union, and the
165+
// copy silently went stale the moment a method was added — the union grew, this
166+
// did not, and the new method failed to typecheck at its own call site with an
167+
// error naming every method *but* the one being added.
143168
function call<T>(
144-
method:
145-
| "login"
146-
| "loginDidcomm"
147-
| "stepUpVta"
148-
| "apiGet"
149-
| "apiPost"
150-
| "mediatorStatus"
151-
| "walletDefaults"
152-
| "signTrustTask"
153-
| "proxyLogin"
154-
| "vaultList"
155-
| "requestTask",
169+
method: BridgeMethod,
156170
params:
157171
| LoginParams
158172
| DidcommLoginParams
@@ -161,6 +175,7 @@ function call<T>(
161175
| ApiPostParams
162176
| SignTrustTaskParams
163177
| ProxyLoginParams
178+
| WalletProfileParams
164179
| VaultListParams
165180
| RequestTaskParams
166181
| Record<string, never>,
@@ -185,6 +200,7 @@ if (!window.vtaWallet) {
185200
walletDefaults: () => call<WalletDefaultsResult>("walletDefaults", {}),
186201
signTrustTask: (params) => call<SignTrustTaskResult>("signTrustTask", params),
187202
proxyLogin: (params) => call<VaultProxyLoginResultView>("proxyLogin", params),
203+
walletProfile: (params) => call<WalletProfileResult>("walletProfile", params),
188204
vaultList: (params) => call<VaultListResultView>("vaultList", params),
189205
requestTask: (params) => call<Record<string, unknown>>("requestTask", params),
190206
};

0 commit comments

Comments
 (0)