From cc6f3239f7a914fc6ed810be0b3fce9a69925081 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 30 Aug 2026 22:35:17 +0200 Subject: [PATCH] feat(rp-login): bind a persona to a site on first sign-in, not beforehand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `proxyLogin` required the *page* to name a vault entry, so a per-site persona had to be bound in advance through the vault panel and a site without one dead-ended. The page's only route to an entry id was `vaultList()` — a second consent prompt that enumerates the user's vault to the site asking — and on a site with nothing bound it returned an empty array with nowhere to go from there. In practice that made the per-site persona a setup step people did not do, which left `login()` and its holder DID as the path of least resistance. The wallet now resolves the entry itself, from the origin the browser attested, and asks the human once when there is nothing bound yet. `entryId` becomes optional; a page that still supplies one keeps the old behaviour exactly. **One prompt, not two.** Choosing the identity a site sees IS the sign-in approval — it names the site and the identity, which is what the prompt already claims to do. A picker in front of a second approve screen would only train the operator to click through both (R7.2). **The picker bypasses the origin-trust short-circuit, deliberately.** The first-use branch calls `requestConsent`, not `gatedConsent`. A "remember this site" tick made against an earlier sign-in meant "log me in as the identity I already chose for you"; it cannot mean "choose a new identity for me and bind it silently", because that question has never been put to the operator. Same reasoning `requestTaskConsent` already carries: origin trust is not capability trust. Delete an entry and the picker returns, trusted origin or not. **The origin match is local and exact.** `vault/list` narrows by `targetOriginPrefix`, and a prefix is not an origin — `https://shop.example` is a prefix of `https://shop.example.evil.test`. Narrowing the set the VTA sends is a bandwidth decision; deciding which entry is this site's is a security decision, so it happens locally with `===`. Tested in both directions, plus scheme and port. **The prompt returns a DID string and nothing else.** Context and signing key are re-derived in the background from the agent's own `list-dids` and `derive-signing-key-id`, so a DID the agent does not host cannot be bound whatever the consent window sends back. A persona with more than one candidate signing key is refused rather than guessed — which key mints the id_token is a real choice with no default, and a wrong guess fails later and opaquely at the VTA — with the operator sent to the vault panel, which has the key picker. Entries are bound to `{kind:"webOrigin"}` *and* the RP's DID when the page named one. The vault panel's did-self-issued form binds a DID target only, so entries created there stay invisible to an origin lookup; that is left alone rather than migrated, since nothing is deployed and the panel is still the place to bind by hand. The ACL caveat is stated in the prompt, beside the DID, rather than after the failure: the relying party decides which identities it admits and nothing this wallet does can add one, so the operator wants the DID on screen while it is still copyable. A failed first sign-in repeats it and names the DID. The entry is kept on failure — it is correct, and deleting it would make the retry-after-enrolment path ask for an identity all over again. `login()` (REST SIOP) is untouched and still signs as the holder DID for every site. That path reads no vault entry at all, so aligning it is a behaviour change to a working flow and wants its own decision. Decision logic sits in `first-use-profile.ts`, free of `chrome` so it is testable; the demo harness gains a `proxyLogin({})` button, since the whole point of #140 was that a flow nothing exercises stays broken. Signed-off-by: Glenn Gore --- packages/demo-rp/login-harness.mjs | 23 ++ packages/extension/src/background.ts | 186 +++++++++++++-- packages/extension/src/bridge-protocol.ts | 19 +- packages/extension/src/confirm.tsx | 213 +++++++++++++++++- packages/extension/src/first-use-profile.ts | 106 +++++++++ packages/extension/src/provider.ts | 22 +- .../tests/first-use-profile.test.mts | 141 ++++++++++++ 7 files changed, 677 insertions(+), 33 deletions(-) create mode 100644 packages/extension/src/first-use-profile.ts create mode 100644 packages/extension/tests/first-use-profile.test.mts diff --git a/packages/demo-rp/login-harness.mjs b/packages/demo-rp/login-harness.mjs index d9ec9dd..958a16d 100644 --- a/packages/demo-rp/login-harness.mjs +++ b/packages/demo-rp/login-harness.mjs @@ -64,6 +64,11 @@ way a relying party would. Keep the wallet's offscreen console open beside this. + + +

The proxy button names no vault entry, which is the point: the wallet resolves +one from this origin, and on a first visit asks which identity to use and binds the answer. A +second click should not prompt for an identity again.

Result

@@ -107,6 +112,24 @@ way a relying party would. Keep the wallet's offscreen console open beside this. } }); + // Deliberately passes NO entryId. A page that supplies one has had to call + // vaultList() to learn it — a second consent prompt that enumerates the + // user's vault to this site. Omitting it is the shape a relying party should + // actually use, so it is the shape the harness exercises. + document.getElementById("proxy").addEventListener("click", async () => { + if (!probe()) return; + show( + "Requesting proxy login…", + "On a first visit the wallet should ask which identity to sign in as.", + ); + try { + const r = await window.vtaWallet.proxyLogin({}); + show("proxyLogin resolved", r); + } catch (e) { + show("proxyLogin rejected", String(e && e.message ? e.message : e)); + } + }); + document.getElementById("siop").addEventListener("click", async () => { if (!probe()) return; show("Requesting SIOP login…", "The wallet should raise a consent prompt."); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 60f7ed3..eec06a1 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -18,6 +18,11 @@ import { } from "./active-vta.js"; import { checkOriginPin, pinOrigin } from "./origin-pin.js"; import { isOriginTrusted, trustOrigin } from "./trusted-sites.js"; +import { + buildProfileEntry, + matchProfileEntry, + PROFILE_SECRET_KIND, +} from "./first-use-profile.js"; import { attestedOrigin, registerPushChannel, @@ -153,6 +158,7 @@ import { type RuntimeVaultProxyLoginResponse, type RuntimeVaultReleaseRequest, type RuntimeVaultReleaseResponse, + type ProxyLoginParams, type RuntimeVaultUpsertRequest, type RuntimeVaultUpsertResponse, type RuntimeApproverStateResponse, @@ -588,7 +594,7 @@ chrome.runtime.onConnect.addListener((port) => { // reports the user's decision (or is closed, which counts as a denial). const pendingConsents = new Map< string, - (approved: boolean, remember: boolean, prfOutputB64u?: string) => void + (approved: boolean, remember: boolean, prfOutputB64u?: string, selectedDid?: string) => void >(); /** @@ -663,7 +669,11 @@ async function requestConsent(args: { * where it comes from inside the signed approve-request — never pass a * page-supplied string here. */ reason?: string; -}): Promise<{ approved: boolean; remember: boolean }> { + /** Render the first-use persona picker: this origin has no vault entry yet, + * so the prompt asks which identity the site should know the user as and + * returns the answer in `selectedDid`. */ + chooseProfile?: boolean; +}): Promise<{ approved: boolean; remember: boolean; selectedDid?: string }> { const consentId = crypto.randomUUID(); const url = chrome.runtime.getURL("confirm.html") + @@ -674,23 +684,28 @@ async function requestConsent(args: { (args.action ? `&action=${encodeURIComponent(args.action)}` : "") + (args.noRemember ? `&noRemember=1` : "") + (args.stepUp ? `&stepUp=1` : "") + + (args.chooseProfile ? `&chooseProfile=1` : "") + (args.reason ? `&reason=${encodeURIComponent(args.reason)}` : "") + (args.changedFromRpDid ? `&changedFrom=${encodeURIComponent(args.changedFromRpDid)}` : ""); - // The reason card needs room, or the decision buttons slide off-screen. - const bounds = await consentWindowBounds(args.reason ? 660 : 560); + // The reason card and the persona picker each need room, or the decision + // buttons slide off-screen — and an Approve the operator has to scroll to + // find is one they approve without reading what is above it. + const bounds = await consentWindowBounds(args.reason ? 660 : args.chooseProfile ? 680 : 560); - return new Promise<{ approved: boolean; remember: boolean }>((resolve) => { + return new Promise<{ approved: boolean; remember: boolean; selectedDid?: string }>((resolve) => { let settled = false; - const settle = (approved: boolean, remember: boolean) => { + const settle = (approved: boolean, remember: boolean, selectedDid?: string) => { if (settled) return; settled = true; pendingConsents.delete(consentId); - resolve({ approved, remember }); + resolve({ approved, remember, ...(selectedDid ? { selectedDid } : {}) }); }; - pendingConsents.set(consentId, settle); + pendingConsents.set(consentId, (approved, remember, _prf, selectedDid) => + settle(approved, remember, selectedDid), + ); chrome.windows.create({ url, type: "popup", ...bounds }, (win) => { const winId = win?.id; @@ -1820,13 +1835,74 @@ async function handleVaultProxyLoginPage( // so require explicit consent naming the requesting origin + target RP. const target = req.params.target as { kind?: string; did?: string } | undefined; const targetDid = target?.kind === "did" ? target.did : undefined; - const approved = await gatedConsent({ + + // Resolve the entry BEFORE prompting. Which prompt to raise depends on + // whether this site already has a persona bound, and a page that calls this + // with no VTA connected should fail without raising one at all. + const resolved = await resolveProfileEntry(req); + if (!resolved.ok) return { ok: false, error: resolved.error }; + + if (resolved.entryId) { + const approved = await gatedConsent({ + origin: req.origin, + action: "Sign in via your VTA (proxied SIOP)", + ...(targetDid ? { rpDid: targetDid } : {}), + }); + if (!approved) return { ok: false, error: "proxy-login denied by user" }; + return dispatchProxyLogin({ ...req.params, entryId: resolved.entryId }); + } + + // First sign-in at this site: nothing is bound yet, so the prompt also asks + // which persona to use and we bind the answer. + // + // `requestConsent`, NOT `gatedConsent`. The trusted-origin short-circuit is + // wrong here for the same reason it is wrong for task consent: a "remember + // this site" tick made against an earlier sign-in meant "you may log me in as + // the identity I already chose for you". It cannot mean "you may choose a new + // identity for me and bind it silently" — that decision has never been put to + // the operator, and binding a persona is the one thing this whole prompt + // exists to ask about. + const decision = await requestConsent({ origin: req.origin, action: "Sign in via your VTA (proxied SIOP)", + chooseProfile: true, ...(targetDid ? { rpDid: targetDid } : {}), }); - if (!approved) return { ok: false, error: "proxy-login denied by user" }; + if (!decision.approved || !decision.selectedDid) { + // An approval with no persona is not an approval of anything — the surface + // cannot produce one (Approve is disabled until a persona is picked), so + // this is either a denial or a malformed reply. Both deny. + return { ok: false, error: "proxy-login denied by user" }; + } + + const bound = await bindProfileEntry(req.origin, decision.selectedDid, targetDid); + if (!bound.ok) return { ok: false, error: bound.error }; + + if (decision.remember) await trustOrigin(req.origin, targetDid); + + const result = await dispatchProxyLogin({ ...req.params, entryId: bound.entryId }); + if (!result.ok) { + // The likeliest cause of a failure on the very first sign-in is the one + // thing this wallet cannot fix: the relying party has never heard of this + // persona. Say so, and name the DID — the prompt said this might happen, + // and this is where the operator finds out it did. The entry is kept: it is + // correct, and deleting it would make the retry-after-enrolment path ask + // them to choose an identity all over again. + return { + ok: false, + error: + `${result.error} — this was the first sign-in as ${decision.selectedDid}. ` + + `If ${req.origin} refused it, that identity needs to be on the site's access list.`, + }; + } + return result; +} +/** Send a resolved proxy-login to the offscreen document, where the holder + * identity and the DIDComm unpacking live. */ +async function dispatchProxyLogin( + params: ProxyLoginParams & { entryId: string }, +): Promise { const c = await readActiveConnection(); if (!c.ok) return { ok: false, error: c.error }; await ensureOffscreenDocument(); @@ -1835,10 +1911,93 @@ async function handleVaultProxyLoginPage( type: OFFSCREEN_VAULT_PROXY_LOGIN, vtaDid: c.conn.vtaDid, restBaseUrl: c.conn.restBaseUrl, - body: req.params, + body: params, })) as RuntimeVaultProxyLoginResponse; } +/** + * Which vault entry a page-initiated proxy login should use. + * + * `entryId` supplied by the page is honoured as-is — that is the pre-existing + * contract, and a page that discovered an id through `vaultList()` has already + * had its own consent prompt for it. Otherwise the entry comes from the origin + * the *browser* attested, never from anything the page said about itself. + * + * `entryId: undefined` with `ok: true` means "this site has no persona yet", + * which is a first-use prompt, not an error. + */ +async function resolveProfileEntry( + req: RuntimeVaultProxyLoginPageRequest, +): Promise<{ ok: true; entryId?: string } | { ok: false; error: string }> { + if (req.params.entryId) return { ok: true, entryId: req.params.entryId }; + + const listed = await handleVaultList({ + type: RUNTIME_VAULT_LIST, + // `targetOriginPrefix` narrows the set the VTA sends back; it does NOT + // decide the answer. A prefix is not an origin — `https://example.com` is a + // prefix of `https://example.com.evil.test` — so `matchProfileEntry` does + // the actual match locally, with `===`, on the attested origin. + filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin }, + }); + if (!listed.ok) return { ok: false, error: listed.error }; + + const match = matchProfileEntry(listed.result.entries, req.origin); + return { ok: true, ...(match ? { entryId: match.id } : {}) }; +} + +/** + * Bind the persona the operator picked to `origin`, as a vault entry. + * + * The prompt returns a DID string and nothing else. Everything the entry needs + * beyond it — the context, the signing key — is re-derived here from the + * agent's own answers, so a DID that is not one the agent hosts cannot be + * bound no matter what the consent window sent back. + */ +async function bindProfileEntry( + origin: string, + did: string, + rpDid: string | undefined, +): Promise<{ ok: true; entryId: string } | { ok: false; error: string }> { + const dids = await handleListDids({ type: RUNTIME_LIST_DIDS }); + if (!dids.ok) return { ok: false, error: dids.error }; + const record = dids.result.dids.find((d) => d.did === did); + if (!record) { + return { ok: false, error: `${did} is not an identity this agent hosts` }; + } + + const derived = await handleDeriveSigningKeyId({ type: RUNTIME_DERIVE_SIGNING_KEY_ID, did }); + if (!derived.ok) return { ok: false, error: derived.error }; + if (derived.result.error) return { ok: false, error: derived.result.error }; + const candidates = derived.result.candidates; + if (candidates.length !== 1) { + // Zero: nothing in the document can sign, and an entry naming a key that + // does not exist fails later, opaquely, at the VTA. More than one: which + // key signs the id_token is a real choice with no default, and picking one + // here would be the wallet guessing. Both send the operator to the vault + // panel, which has the key picker this prompt deliberately does not. + return { + ok: false, + error: + candidates.length === 0 + ? `no signing key could be derived from ${did}` + : `${did} has ${candidates.length} possible signing keys — bind it from the wallet's vault panel, which lets you choose one`, + }; + } + + const upserted = await handleVaultUpsert({ + type: RUNTIME_VAULT_UPSERT, + ...buildProfileEntry({ + origin, + did, + contextId: record.contextId, + signingKeyId: candidates[0]!, + ...(rpDid ? { rpDid } : {}), + }), + }); + if (!upserted.ok) return { ok: false, error: upserted.error }; + return { ok: true, entryId: upserted.result.entry.id }; +} + // Authenticated POST proxied through the wallet (host permission → no CORS). async function handleApiPost(req: RuntimeApiPostRequest): Promise { const base = req.params.baseUrl.replace(/\/+$/, ""); @@ -2271,8 +2430,9 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { } if ((message as { type?: string })?.type === RUNTIME_CONSENT_RESULT) { - const { consentId, approved, remember, prfOutputB64u } = message as RuntimeConsentResult; - pendingConsents.get(consentId)?.(approved, !!remember, prfOutputB64u); + const { consentId, approved, remember, prfOutputB64u, selectedDid } = + message as RuntimeConsentResult; + pendingConsents.get(consentId)?.(approved, !!remember, prfOutputB64u, selectedDid); return false; } diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 163c7c1..e4ae453 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -399,6 +399,13 @@ export interface RuntimeConsentResult { * same-browser relay can sign the decision without a pre-unlocked session. * Never sent for a denial, and never cached. */ prfOutputB64u?: string; + /** First-use profile prompt only: the persona DID the operator picked, which + * the background then binds to the requesting origin as a vault entry. + * Approving that prompt without a selection is not a thing the surface can + * produce — Approve stays disabled until one is chosen — so the background + * treats an approval that arrives without it as a denial rather than + * guessing a persona on the operator's behalf. */ + selectedDid?: string; } /** confirm popup → background: resolve + verify an RP DID. */ @@ -1219,7 +1226,17 @@ export type RuntimeVaultProxyLoginResponse = * request body — the content script + background unwrap `params` * and reuse the same offscreen pipeline. */ export interface ProxyLoginParams { - entryId: string; + /** The vault entry to log in with. + * + * **Optional, and normally omitted.** When absent the wallet resolves the + * entry itself from the browser-attested origin, and — on a site with no + * persona bound yet — asks the operator which one to use and binds it. + * + * A page that supplies one is naming an entry it learned from `vaultList()`, + * which is a consent prompt that enumerates the user's vault to the site. + * Omitting it is strictly better for the user: one prompt instead of two, + * and the site never learns what else is in the vault. */ + entryId?: string; target?: VaultEntryView["targets"][number]; /** Caller-supplied nonce — typically the value the RP returned * from its `/auth/challenge` endpoint, which the page threads diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index bfed787..20453b1 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -7,7 +7,10 @@ import { extractAgentNames, withoutScheme } from "./agent-name.js"; import "./theme.css"; import { RUNTIME_CONSENT_RESULT, + RUNTIME_LIST_DIDS, RUNTIME_VERIFY_RP_DID, + type DidRecordView, + type RuntimeListDidsResponse, type RuntimeVerifyRpDidResponse, type VerifyRpDidResult, } from "./bridge-protocol.js"; @@ -55,22 +58,47 @@ const action = params.get("action"); // attributed, not trusted: rendered as plain text, never markup. const isStepUp = params.get("stepUp") === "1"; const stepUpReason = params.get("reason"); +// First sign-in at this site: no persona is bound to it yet, so this prompt +// also asks WHICH persona to use and the background binds the answer as a vault +// entry. The picker is part of the same decision, not a second one — choosing +// the identity a site sees IS the approval, and splitting it into two screens +// would only train the operator to click through both. +const isChooseProfile = params.get("chooseProfile") === "1"; // M5: when set, the rpDid this origin previously used. Render a // louder warning so the operator sees the swap and decides // whether to approve it. const changedFromRpDid = params.get("changedFrom"); -function decide(approved: boolean, remember = false, prfOutputB64u?: string): void { +function decide( + approved: boolean, + remember = false, + prfOutputB64u?: string, + selectedDid?: string, +): void { chrome.runtime.sendMessage({ type: RUNTIME_CONSENT_RESULT, consentId, approved, remember, ...(prfOutputB64u ? { prfOutputB64u } : {}), + ...(selectedDid ? { selectedDid } : {}), }); window.close(); } +/** `collapseDid` as a plain string. + * + * An `