From 50caf36c52bea1604b0e5d41288d33dd7e812c46 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 30 Aug 2026 23:52:03 +0200 Subject: [PATCH 1/2] feat(rp-login): login() signs in as the per-site persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `login()` — the REST SIOPv2 path, and the one an RP page reaches for first — signed as the wallet's holder DID for **every site**, unconditionally and with no signal. It read no vault entry and took no parameter that could change that, while `signin-flow.tsx` told the operator, on screen, that "each site gets its own identity" and "a site sees an identity you use only there — never your wallet's own address". That claim was true of `proxyLogin` and false of the button next to it. Now the identity is resolved per origin: a bound persona if this site has one, the wallet's own identity if the operator chose it for this site, and otherwise a prompt that asks. The explainer becomes true. **The holder stays as an offered answer, not a silent fallback.** The RP's ACL is checked against whichever DID signs in, and every enrolment made before personas existed names the holder — `vtc admin invite --did ` puts it there. Dropping the route would break those sites on the next sign-in with an RP refusal as the only clue. So the picker lists "My wallet's own identity" last, labelled with what it costs (every site holding it can recognise the same person), and the choice is remembered per origin in `site-identity.ts`. It is offered **only** where it can be honoured. `login()` self-issues from a key the browser holds; `proxyLogin` and `walletProfile` mint through a vault entry at the VTA, and the holder is not one — so `allowHolder` gates the option rather than presenting a choice that fails after it is made. **A persona always beats a recorded holder choice.** It is the more specific statement about the site and the one the operator can see and revoke in the vault; reading the local record first would let a stale choice mask an entry bound later through `proxyLogin`, and the sign-in would use an identity the vault contradicts. Binding a persona clears the record, so there is never a second answer sitting behind the first. The id_token source is now a parameter rather than a branch: `SiopIdTokenMinter` with `selfIssuedMinter()` for the holder and a VTA-backed one in the offscreen for a persona. That keeps `siop` at its layer — the persona mint needs `vault`, which is two layers up — and `loginViaSiop` stops taking a `SigningIdentity` it can only use one way. `minter.did` is load-bearing beyond the signature: the challenge is requested for it, and the RP refuses unless the DID it issued the challenge to is the one that signed (`session.did != input.signer_did` in vti-common's `handle_authenticate`). The persona minter therefore reads `principalDid` back from the entry rather than assuming it — it is maintainer-derived, and an entry rotated at the VTA signs as something the wallet never chose. `holderDid` in the result is now the DID that actually signed. Reporting the wallet's own for a persona login would tell the page it is talking to an identity that signed nothing in that flow. `loginDidcomm` is unchanged and still authenticates as the holder; it needs the channel to sign as a persona, which is the next commit. Signed-off-by: Glenn Gore --- packages/core/src/siop/login-client.ts | 48 ++++++-- packages/extension/src/background.ts | 110 +++++++++++++++++- packages/extension/src/bridge-protocol.ts | 10 ++ packages/extension/src/confirm.tsx | 74 +++++++++--- packages/extension/src/first-use-profile.ts | 30 +++++ packages/extension/src/offscreen.ts | 59 +++++++++- packages/extension/src/site-identity.ts | 77 ++++++++++++ .../tests/first-use-profile.test.mts | 37 ++++++ 8 files changed, 414 insertions(+), 31 deletions(-) create mode 100644 packages/extension/src/site-identity.ts diff --git a/packages/core/src/siop/login-client.ts b/packages/core/src/siop/login-client.ts index 07b5b27..7a21a02 100644 --- a/packages/core/src/siop/login-client.ts +++ b/packages/core/src/siop/login-client.ts @@ -21,13 +21,46 @@ export interface SiopLoginResult { timings: TimingMark[]; } +/** + * Where the `id_token` comes from. + * + * Two producers, and the difference is not an implementation detail: the + * holder self-issues locally with a key the browser holds, while a per-site + * persona is minted **by the VTA**, which is the only place that persona's + * signing key exists. The RP cannot tell them apart and should not — both are + * an `id_token` signed by `did` — but the caller has to choose, so the choice + * is a parameter rather than a branch buried in here. + * + * `did` is load-bearing beyond the signature: the challenge is requested for + * it, and the RP refuses unless the DID it issued the challenge to is the one + * that signed (`session.did != input.signer_did` in vti-common's + * `handle_authenticate`). So a minter whose `did` disagrees with what it + * actually signs with fails at the RP, not here. + */ +export interface SiopIdTokenMinter { + /** The DID the `id_token` is issued by — its `iss` and `sub`. */ + readonly did: string; + mint(input: { audience: string; nonce: string }): Promise; +} + +/** The holder self-issuing locally, which is what this module did before the + * source became pluggable. */ +export function selfIssuedMinter(signing: SigningIdentity): SiopIdTokenMinter { + return { + did: signing.did, + mint: ({ audience, nonce }) => + Promise.resolve(issueIdToken({ identity: signing, audience, nonce })), + }; +} + export interface SiopLoginOptions { /** Base URL of the RP's auth API (e.g. `https://hosting.example/api`). */ baseUrl: string; /** The RP's identifier — its server DID — used as the `id_token` `aud`. */ rpDid: string; - /** The holder's Ed25519 signing identity (from `generateOrLoadHolderIdentity().signing`). */ - signing: SigningIdentity; + /** Who signs in, and how the `id_token` is produced. Use + * {@link selfIssuedMinter} for the holder's own identity. */ + minter: SiopIdTokenMinter; /** Optional ephemeral session pubkey (`z6Mk…` Ed25519 multikey) to bind * for subsequent trust-task proofs. */ sessionPubkeyB58btc?: string; @@ -51,7 +84,7 @@ export async function loginViaSiop( const challengeRes = await fetchFn(`${base}/auth/challenge`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ did: opts.signing.did }), + body: JSON.stringify({ did: opts.minter.did }), }); if (!challengeRes.ok) { throw new Error( @@ -67,9 +100,10 @@ export async function loginViaSiop( }; sw.mark("challenge"); - // 2. Self-issue the id_token — aud = the RP's DID, nonce = the challenge. - const idToken = issueIdToken({ - identity: opts.signing, + // 2. Mint the id_token — aud = the RP's DID, nonce = the challenge. Either + // self-issued here, or minted by the VTA for a per-site persona whose key + // the browser does not hold. + const idToken = await opts.minter.mint({ audience: opts.rpDid, nonce: challenge.challenge, }); @@ -79,7 +113,7 @@ export async function loginViaSiop( const envelope = { id: `urn:uuid:${globalThis.crypto.randomUUID()}`, type: TASK_AUTH_AUTHENTICATE, - issuer: opts.signing.did, + issuer: opts.minter.did, issuedAt: new Date().toISOString(), payload: { id_token: idToken, diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index d26e605..8106e22 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -18,8 +18,15 @@ import { } from "./active-vta.js"; import { checkOriginPin, pinOrigin } from "./origin-pin.js"; import { isOriginTrusted, trustOrigin } from "./trusted-sites.js"; +import { + forgetSiteIdentity, + HOLDER_IDENTITY, + prefersHolderIdentity, + rememberHolderIdentity, +} from "./site-identity.js"; import { buildProfileEntry, + decideSiteIdentity, matchProfileEntry, PROFILE_SECRET_KIND, } from "./first-use-profile.js"; @@ -676,6 +683,10 @@ async function requestConsent(args: { * so the prompt asks which identity the site should know the user as and * returns the answer in `selectedDid`. */ chooseProfile?: boolean; + /** Offer the wallet's own identity as one of the answers, returned as + * {@link HOLDER_IDENTITY}. Only `login()` sets it — the proxy paths mint + * through a vault entry, and the holder is not one. */ + allowHolder?: boolean; }): Promise<{ approved: boolean; remember: boolean; selectedDid?: string }> { const consentId = crypto.randomUUID(); const url = @@ -688,6 +699,7 @@ async function requestConsent(args: { (args.noRemember ? `&noRemember=1` : "") + (args.stepUp ? `&stepUp=1` : "") + (args.chooseProfile ? `&chooseProfile=1` : "") + + (args.allowHolder ? `&allowHolder=1` : "") + (args.reason ? `&reason=${encodeURIComponent(args.reason)}` : "") + (args.changedFromRpDid ? `&changedFrom=${encodeURIComponent(args.changedFromRpDid)}` @@ -917,20 +929,112 @@ async function handleLogin(req: RuntimeLoginRequest): Promise { + // No attested origin means no site to bind a persona to. The wallet's own + // identity is the only honest answer, and it is what this path already did. + if (!origin) return { ok: true, did: holderDid, bound: false }; + + const listed = await handleVaultList({ + type: RUNTIME_VAULT_LIST, + filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin }, + }); + if (!listed.ok) return { ok: false, error: listed.error }; + + const decision = decideSiteIdentity( + listed.result.entries, + origin, + await prefersHolderIdentity(origin), + ); + + if (decision.kind === "holder") return { ok: true, did: holderDid, bound: false }; + if (decision.kind === "persona") { + const did = await principalDidFor(origin, decision.entryId); + if (!did.ok) return { ok: false, error: did.error }; + return { ok: true, entryId: decision.entryId, did: did.did, bound: false }; + } + + // `requestConsent`, not `gatedConsent` — a remembered origin consented to + // being signed in as an identity already chosen, never to one being chosen + // for it. Same reasoning as the proxy-login path. + const chosen = await requestConsent({ + origin, + rpDid, + holderDid, + chooseProfile: true, + allowHolder: true, + }); + if (!chosen.approved || !chosen.selectedDid) { + return { ok: false, error: "login denied by user" }; + } + if (chosen.remember) await trustOrigin(origin, rpDid); + + if (chosen.selectedDid === HOLDER_IDENTITY) { + await rememberHolderIdentity(origin); + return { ok: true, did: holderDid, bound: false }; + } + + const bound = await bindProfileEntry(origin, chosen.selectedDid, rpDid); + if (!bound.ok) return { ok: false, error: bound.error }; + // A persona now answers for this origin, so a holder record left behind would + // be a second answer that never wins but is read on every sign-in. + await forgetSiteIdentity(origin); + return { ok: true, entryId: bound.entryId, did: chosen.selectedDid, bound: true }; } async function handleLoginDidcomm( diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 98a935e..5cb61f6 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1632,6 +1632,16 @@ export interface OffscreenRestLoginRequest { * fills this from the active connection before forwarding. */ vtaDid: string; params: LoginParams; + /** The vault entry whose persona signs in, when this origin has one bound. + * Absent means the wallet's own holder identity — either because the + * operator chose it for this site or because there is no attested origin to + * bind a persona to. Resolved in the background, where the vault and the + * operator's recorded choice live; the offscreen only turns it into the + * matching `id_token` producer. */ + entryId?: string; + /** REST base for the VTA session the persona mint goes through. Unused for + * a holder login, which contacts only the RP. */ + restBaseUrl?: string; } /** background → offscreen: run a VTA-approval step-up. Reply is a diff --git a/packages/extension/src/confirm.tsx b/packages/extension/src/confirm.tsx index 20453b1..6583ccc 100644 --- a/packages/extension/src/confirm.tsx +++ b/packages/extension/src/confirm.tsx @@ -3,6 +3,7 @@ import { StrictMode, useEffect, useState } from "react"; import { createRoot } from "react-dom/client"; import { collapseDid, splitDid, type DidPart } from "./did-display.js"; +import { HOLDER_IDENTITY } from "./site-identity.js"; import { extractAgentNames, withoutScheme } from "./agent-name.js"; import "./theme.css"; import { @@ -64,6 +65,13 @@ const stepUpReason = params.get("reason"); // 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"; +// Whether "my wallet's own identity" is one of the answers. +// +// Only `login()` can honour it: it self-issues the id_token from a key the +// browser holds. `proxyLogin` and `walletProfile` cannot — the VTA mints as a +// vault entry's persona, and the holder is not one — so offering it there would +// present a choice that fails after it is made. +const allowsHolder = params.get("allowHolder") === "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. @@ -483,9 +491,11 @@ function Confirm() { return; } setPersonas(reply.result.dids); - // One persona is not a choice. Preselect it so the operator is deciding - // the thing that is actually in question — whether this site gets an - // identity at all — instead of confirming a dropdown with one row. + // Preselect a sole persona as the recommended default — the wallet's + // own identity is always in the list beside it, so this is offering a + // default rather than removing the choice. Approve stays disabled with + // no personas at all, so the operator has to pick the holder option + // deliberately rather than inherit it. if (reply.result.dids.length === 1) setSelectedDid(reply.result.dids[0]!.did); }) .catch((e: unknown) => { @@ -740,7 +750,7 @@ function Confirm() { ) : personas === null ? (
Loading your identities…
- ) : personas.length === 0 ? ( + ) : personas.length === 0 && !allowsHolder ? (
Your agent hosts no identities yet, so there is nothing to sign in as. Create one in the wallet (Vault → identities) and try again. @@ -767,6 +777,18 @@ function Confirm() { {collapsedDidText(d.did)} · {d.contextId} ))} + {/* The wallet's own identity, offered explicitly rather than + used as a silent fallback. Every ACL enrolment made before + per-site personas existed names this DID, so removing the + route would break those sites with an RP refusal as the + only signal. Last in the list, and labelled with what it + costs, because it is the weaker answer — not the default. */} + {allowsHolder && ( + + )} {selectedDid && (
- {selectedDid} + {selectedDid === HOLDER_IDENTITY ? (holderDid ?? "") : selectedDid}
)} {/* The ACL caveat. Stated as a fact about the site, not a wallet @@ -786,21 +808,35 @@ function Confirm() { identities it admits, and nothing this wallet does can add one. Said here rather than after the failure so the operator can copy the DID while it is on screen. */} -

- The site has to allow this identity before it will let you in. If sign-in is - refused, ask{" "} - {originHost ? ( - {originHost} - ) : ( - "the site" - )}{" "} - to add the identity above to its access list, then try again. -

- {personas.length > 1 && ( -

- Using an identity you already use elsewhere lets both sites work out you are the - same person. A fresh one for this site keeps them separate. + {selectedDid === HOLDER_IDENTITY ? ( + // Different warning, because the trade is the opposite one. + // The holder DID already works at every site that enrolled it, + // so there is no ACL step — the cost is that those sites can + // all recognise the same person. +

+ This is the same identity you use everywhere else, so any site that has it can + work out you are the same person. Choose it when this site's access list + already names your wallet's identity.

+ ) : ( + <> +

+ The site has to allow this identity before it will let you in. If sign-in is + refused, ask{" "} + {originHost ? ( + {originHost} + ) : ( + "the site" + )}{" "} + to add the identity above to its access list, then try again. +

+ {personas.length > 1 && ( +

+ Using an identity you already use elsewhere lets both sites work out you are + the same person. A fresh one for this site keeps them separate. +

+ )} + )} )} diff --git a/packages/extension/src/first-use-profile.ts b/packages/extension/src/first-use-profile.ts index e681559..c7c8e76 100644 --- a/packages/extension/src/first-use-profile.ts +++ b/packages/extension/src/first-use-profile.ts @@ -104,3 +104,33 @@ export function buildProfileEntry( }, }; } + +/** What a sign-in at this origin should do about identity. */ +export type SiteIdentityDecision = + | { kind: "persona"; entryId: string } + | { kind: "holder" } + | { kind: "ask" }; + +/** + * Resolve the identity for a sign-in, from the two places the answer can live. + * + * A bound persona is a vault entry; choosing the wallet's own identity is a + * local record (`site-identity.ts`). **A persona always wins**, and the order + * is not arbitrary: a persona is the more specific statement about this site, + * and it is the one the operator can see and revoke in the vault. Reading the + * local record first would let a stale holder choice mask an entry the operator + * later bound through `proxyLogin`, and the sign-in would quietly use a + * different identity than the vault says it does. + * + * `ask` means neither exists — raise the picker. + */ +export function decideSiteIdentity( + entries: readonly VaultEntryView[], + origin: string, + prefersHolder: boolean, +): SiteIdentityDecision { + const match = matchProfileEntry(entries, origin); + if (match) return { kind: "persona", entryId: match.id }; + if (prefersHolder) return { kind: "holder" }; + return { kind: "ask" }; +} diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 5446f8b..2a7a2f1 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -16,6 +16,8 @@ import { IndexedDBKVStore, loginViaTrustTask, loginViaSiop, + selfIssuedMinter, + type SiopIdTokenMinter, claimInboundDocument, type MediatorConnection, MediatorSessionBridge, @@ -2794,12 +2796,65 @@ async function doRestLogin( // (challenge → issueIdToken → authenticate), just running in the // context that owns the cache. const { signing } = await loadHolder(req.vtaDid); + + // Which identity signs in was decided in the background, where the vault and + // the operator's choice live. Here it is only the difference between two + // id_token producers: the holder self-issues from a key this document holds, + // while a persona is minted by the VTA — the only place that key exists. + const minter = req.entryId + ? await personaMinter(req.vtaDid, req.restBaseUrl, req.entryId) + : selfIssuedMinter(signing); + const tokens = await loginViaSiop({ baseUrl: req.params.baseUrl, rpDid: req.params.rpDid, - signing, + minter, }); - return { ok: true, result: { ...tokens, holderDid: signing.did } }; + // The DID the RP actually authenticated, not the wallet's own. Reporting + // `signing.did` for a persona login would tell the page it is talking to an + // identity that never signed anything in this flow. + return { ok: true, result: { ...tokens, holderDid: minter.did } }; +} + +/** + * An `id_token` minter backed by `vault/proxy-login/0.2`. + * + * The persona's signing key never leaves the VTA, so the wallet cannot issue + * this token — it asks the VTA to, threading the RP's challenge through as the + * `nonce` so the result passes the RP's exact-match check. The `SessionBlob` + * comes back with the token in an `Authorization` header, which is the shape + * `vault/proxy-login` has always returned for did-self-issued entries. + * + * The DID is read from the entry rather than assumed, because `principalDid` is + * maintainer-derived: an entry whose secret was rotated at the VTA signs as + * something the wallet never chose, and the challenge must be requested for + * whatever actually signs or the RP refuses on `signer_did` mismatch. + */ +async function personaMinter( + vtaDid: string, + restBaseUrl: string | undefined, + entryId: string, +): Promise { + const { session, holder, service } = await getVtaSession(vtaDid, restBaseUrl); + const listed = await vaultList(session, { holder, service }); + const entry = listed.entries.find((e) => e.id === entryId); + if (!entry?.principalDid) { + throw new Error(`vault entry ${entryId} names no persona DID`); + } + return { + did: entry.principalDid, + mint: async ({ nonce }) => { + const res = await vaultProxyLogin(session, { holder, service, entryId, nonce }); + const auth = res.sessionBlob.headers?.find( + (h) => h.name.toLowerCase() === "authorization", + ); + const token = auth ? /^\s*Bearer\s+(.+?)\s*$/i.exec(auth.value)?.[1] : undefined; + if (!token) { + throw new Error("vault/proxy-login: SessionBlob carried no id_token"); + } + return token; + }, + }; } async function doDidcommLogin( diff --git a/packages/extension/src/site-identity.ts b/packages/extension/src/site-identity.ts new file mode 100644 index 0000000..f57a248 --- /dev/null +++ b/packages/extension/src/site-identity.ts @@ -0,0 +1,77 @@ +/** + * Which identity a site knows the user as, when the answer is "the wallet's + * own". + * + * Persona choices need no store: binding one *is* a vault entry, and + * `matchProfileEntry` reads it back. Choosing the holder DID creates nothing — + * there is no entry to find — so without a record here the operator would be + * asked again on every sign-in, and a prompt that reappears after being + * answered is one people learn to click through (R7.2). + * + * So this stores exactly one fact, per origin: *the operator chose the holder + * for this site.* It is a decision, not a cache — nothing here is derivable + * from the vault, which is why it cannot live there. + * + * ## Why the holder is offered at all + * + * A per-site persona is the better answer and the default. But the RP's ACL is + * checked against whichever DID signs in, and every enrolment made before + * personas existed names the holder DID. Removing that route would break those + * sites on the next sign-in, with a refusal from the RP as the only signal. So + * it stays, as an explicit choice the operator makes with the consequence on + * screen — rather than as a silent fallback, which is the thing this whole + * change set exists to remove. + * + * Storage: `chrome.storage.local`, key prefix `site-identity:`. Revoked by + * clearing extension storage, or by binding a persona — a persona always wins, + * because it is the more specific statement about this site. + */ + +const KEY_PREFIX = "site-identity:"; + +/** The value the consent picker returns when the operator chooses the wallet's + * own identity. Not a DID: it must never be mistaken for one, and a sentinel + * that cannot parse as a DID fails loudly if it ever reaches a place expecting + * one. */ +export const HOLDER_IDENTITY = "holder" as const; + +interface SiteIdentityRecord { + kind: typeof HOLDER_IDENTITY; + chosenAt: number; +} + +function key(origin: string): string { + return `${KEY_PREFIX}${origin}`; +} + +/** Did the operator choose the wallet's own identity for this site? */ +export async function prefersHolderIdentity(origin: string): Promise { + if (!origin) return false; + const k = key(origin); + const got = await chrome.storage.local.get(k); + return (got[k] as SiteIdentityRecord | undefined)?.kind === HOLDER_IDENTITY; +} + +/** Record that this site signs in as the wallet's own identity. */ +export async function rememberHolderIdentity(origin: string): Promise { + if (!origin) return; + const record: SiteIdentityRecord = { kind: HOLDER_IDENTITY, chosenAt: Date.now() }; + await chrome.storage.local.set({ [key(origin)]: record }); +} + +/** Forget the choice, so the next sign-in asks again. Used when a persona is + * bound for the same origin: leaving a stale holder record behind would make + * the answer depend on which of two stores was read first. */ +export async function forgetSiteIdentity(origin: string): Promise { + if (!origin) return; + await chrome.storage.local.remove(key(origin)); +} + +/** Every origin pinned to the wallet's own identity, for the options page. */ +export async function listHolderIdentitySites(): Promise { + const all = await chrome.storage.local.get(null); + return Object.keys(all) + .filter((k) => k.startsWith(KEY_PREFIX)) + .map((k) => k.slice(KEY_PREFIX.length)) + .sort(); +} diff --git a/packages/extension/tests/first-use-profile.test.mts b/packages/extension/tests/first-use-profile.test.mts index 34baf3d..35c49c9 100644 --- a/packages/extension/tests/first-use-profile.test.mts +++ b/packages/extension/tests/first-use-profile.test.mts @@ -5,6 +5,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { buildProfileEntry, + decideSiteIdentity, matchProfileEntry, profileLabelFor, PROFILE_SECRET_KIND, @@ -139,3 +140,39 @@ test("the label is the hostname, and never a fabricated one", () => { // Unparseable input comes back verbatim rather than as a guess. assert.equal(profileLabelFor("not a url"), "not a url"); }); + +// ─── Which identity a login() at this origin uses ─── + +test("a bound persona is used, and beats a stale holder choice", () => { + // The operator once chose the wallet's own identity here, then later bound a + // persona (through proxyLogin, say). The persona is the more specific + // statement and the one visible in the vault, so reading the local record + // first would sign in as an identity the vault contradicts. + const e = entry("01A", [{ kind: "webOrigin", origin }]); + assert.deepEqual(decideSiteIdentity([e], origin, true), { kind: "persona", entryId: "01A" }); + assert.deepEqual(decideSiteIdentity([e], origin, false), { kind: "persona", entryId: "01A" }); +}); + +test("the recorded holder choice is honoured when no persona is bound", () => { + assert.deepEqual(decideSiteIdentity([], origin, true), { kind: "holder" }); +}); + +test("neither recorded means ask — never a silent holder login", () => { + // The whole point of the change: login() used to sign as the holder here, + // unconditionally and with no signal, while the wallet told the operator + // that each site gets its own identity. + assert.deepEqual(decideSiteIdentity([], origin, false), { kind: "ask" }); +}); + +test("another site's persona does not answer for this one", () => { + const other = entry("01A", [{ kind: "webOrigin", origin: "https://other.example" }]); + assert.deepEqual(decideSiteIdentity([other], origin, false), { kind: "ask" }); + assert.deepEqual(decideSiteIdentity([other], origin, true), { kind: "holder" }); +}); + +test("a look-alike origin's entry never satisfies the real one", () => { + const lookalike = entry("01A", [ + { kind: "webOrigin", origin: "https://shop.example.evil.test" }, + ]); + assert.deepEqual(decideSiteIdentity([lookalike], origin, false), { kind: "ask" }); +}); From a3eed3276c20f8e87648a116b887b6c6c3ff86d9 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Sun, 30 Aug 2026 23:57:02 +0200 Subject: [PATCH 2/2] feat(rp-login): loginDidcomm() signs in as the per-site persona MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The other half. `loginDidcomm` authenticates by the **proof on the document**, and the channel puts that proof there — so signing in as a per-site persona meant a channel that could sign as one, and a persona's key never leaves the VTA. `signOutboundTask` now takes a `TaskSigner` — `{ did, sign(envelope) }` — instead of a private key. Two implementations: `localTaskSigner` for the holder's own key, and `vaultTaskSigner`, which asks the VTA to sign via `vault/sign-trust-task/0.2`. The channel cannot tell them apart, and neither can the RP: it verifies a proof against a DID and never learns where the bytes were produced. **The signer stays REQUIRED.** Widening the type does not weaken the rule CLAUDE.md records — a channel that could be built without one would still silently send unsigned documents. What changed is only where the key is. Channel options accept `SigningIdentity | TaskSigner` and normalise once at construction, because the fourteen deprecated `*Rest` helpers thread an identity straight through their own public options types and rewriting all of them would say something none of their callers need to know. **Transport sender and document signer are now separate, because the RP treats them as separate.** `handle_authenticate` establishes the caller from the signature (`session.did != input.signer_did`), not from who delivered the frame. So a persona login rides the wallet's own transport — there is no second mediator session, and the persona has no local key to open one — while the documents are issued by and signed as the persona. `SessionIdentity` gains `documentSigner` rather than widening `signing`, which would have handed a keyless signer to `tspHolderIdentityFromSecret` and failed far from the cause. **One identity across both steps.** `loginViaTrustTask` takes `subject` and uses it for the challenge AND the authenticate. Requesting the challenge for the persona and issuing the authenticate as the holder would be refused by the RP on the signer check — after the operator had already approved the sign-in — so a test pins both envelopes, not just the first. The `signing.did !== holder.did` guard is gone from `loginViaTrustTask`. It could not survive a channel signing as a persona, and it was checking the wrong thing anyway: that function never signs. The rule it stood for is enforced where the signature happens, on every outbound document, by `signOutboundTask`'s existing issuer check. Its test now pins the behaviour that replaced it rather than the guard that moved. Signed-off-by: Glenn Gore --- packages/core/src/rp-login/trust-task.ts | 40 ++++++---- packages/core/src/vault/index.ts | 1 + packages/core/src/vault/task-signer.ts | 78 ++++++++++++++++++++ packages/core/src/vta/auth-tasks.ts | 19 +++-- packages/core/src/vta/didcomm.ts | 9 ++- packages/core/src/vta/rest-channel.ts | 9 ++- packages/core/src/vta/trust-task.ts | 72 ++++++++++++++++-- packages/core/src/vta/tsp-channel.ts | 9 ++- packages/core/tests/rp-login.trust-task.mjs | 41 +++++++--- packages/core/tests/vta.outbound-signing.mjs | 5 +- packages/extension/src/background.ts | 27 ++++++- packages/extension/src/bridge-protocol.ts | 7 ++ packages/extension/src/offscreen.ts | 77 ++++++++++++++++--- 13 files changed, 326 insertions(+), 68 deletions(-) create mode 100644 packages/core/src/vault/task-signer.ts diff --git a/packages/core/src/rp-login/trust-task.ts b/packages/core/src/rp-login/trust-task.ts index 08bf2f9..4270002 100644 --- a/packages/core/src/rp-login/trust-task.ts +++ b/packages/core/src/rp-login/trust-task.ts @@ -23,7 +23,6 @@ // (affinidi-webvh-service #171). Against one that does not, the challenge comes // back `unsupportedType` and the caller can fall back to `loginViaDidcomm`. -import type { SigningIdentity } from "../siop/self-issued.js"; import { authenticateSession, requestAuthChallenge } from "../vta/auth-tasks.js"; import type { TrustTaskSender } from "../vta/channel.js"; import type { Identity } from "../didcomm/index.js"; @@ -45,12 +44,19 @@ export interface TrustTaskLoginOptions { /** Any transport that can carry a Trust Task to the RP. A `VtaSession` * built against the RP's control DID gives the full chain. */ sender: TrustTaskSender; - /** The wallet's holder identity — the envelope `issuer`, and the VID the - * RP's ACL is checked against. */ + /** The wallet's holder identity — the transport identity, and the default + * document `issuer`. */ holder: Identity; - /** Signs the documents. Its DID MUST be the holder's: the proof is what - * authenticates, so a signature by anything else authenticates nobody. */ - signing: SigningIdentity; + /** + * DID to log in AS, when that is not the holder — a per-site persona. + * + * The RP checks the challenge subject against the DID that signed + * (`session.did != input.signer_did` in vti-common's `handle_authenticate`), + * so this has to be both: the challenge is requested for it, and the channel + * has to sign as it. Supplying one whose key the channel cannot sign with + * fails at `signOutboundTask`, locally, naming both DIDs. + */ + subject?: string; /** The RP's control DID + keyAgreement — the envelope `recipient`. */ service: RemoteDidcommEndpoint; /** Capability tags to request. The RP decides what it grants. */ @@ -71,29 +77,33 @@ export async function loginViaTrustTask( opts: TrustTaskLoginOptions, ): Promise { const { sender, holder, service } = opts; + // Who is signing in. The holder unless a persona was named — and the same + // value has to reach both steps, or the RP refuses on the signer check. + const subject = opts.subject ?? holder.did; - if (opts.signing.did !== holder.did) { - // Refused here rather than at the RP, because the failure the RP returns - // for this is `permissionDenied` with no hint that the cause is local. - throw new Error( - `rp-login: signing identity ${opts.signing.did} is not the holder ${holder.did}; ` + - "the document proof is what authenticates, so it must be the holder's", - ); - } + // The guard that used to live here — "the signing identity must be the + // holder" — has moved to where it can actually be checked. + // `loginViaTrustTask` never signs; the channel does, and `signOutboundTask` + // compares the envelope's issuer against the signer's DID on every outbound + // document. Re-asserting it here would only have said the holder is the only + // possible signer, which stopped being true when a channel could sign as a + // persona whose key lives at the VTA. const challenge = await requestAuthChallenge(sender, { holder, service, + issuer: subject, // The RP binds the challenge to the identity it verified, so naming a // subject here cannot widen anything — it is a statement of intent that // lets the RP refuse early if it disagrees. - subject: holder.did, + subject, purpose: "login", }); const authed = await authenticateSession(sender, { holder, service, + issuer: subject, challenge: challenge.challenge, sessionId: challenge.sessionId, ...(opts.scope && opts.scope.length > 0 ? { scope: opts.scope } : {}), diff --git a/packages/core/src/vault/index.ts b/packages/core/src/vault/index.ts index 5ede1c6..74bc0cf 100644 --- a/packages/core/src/vault/index.ts +++ b/packages/core/src/vault/index.ts @@ -5,4 +5,5 @@ export * from "./delete.js"; export * from "./release.js"; export * from "./proxy-login.js"; export * from "./sign-trust-task.js"; +export * from "./task-signer.js"; export type { VtaAuthInputs } from "../vta/auth.js"; diff --git a/packages/core/src/vault/task-signer.ts b/packages/core/src/vault/task-signer.ts new file mode 100644 index 0000000..2e2eb60 --- /dev/null +++ b/packages/core/src/vault/task-signer.ts @@ -0,0 +1,78 @@ +// A `TaskSigner` whose key lives at the VTA. +// +// A per-site persona's signing key is generated at the agent and never leaves +// it — that is the security property the whole persona design rests on, and it +// is why the browser cannot put a proof on a document issued by one. So it +// asks: `vault/sign-trust-task/0.2` canonicalises and signs the envelope with +// the entry's key and hands it back. +// +// From the channel's side this is indistinguishable from a local key, which is +// the point of `TaskSigner`. From the RP's side it is indistinguishable too: it +// verifies a Data Integrity proof against the issuer's DID document and never +// learns where the bytes were produced. +// +// ## The signing channel is not the channel being signed for +// +// `vaultSignTrustTask` is itself a Trust Task, sent to the **VTA** over the +// wallet's own holder-signed session. The document it signs is bound for a +// **relying party** over a different channel. Passing the RP channel here would +// send a vault task to a party that has no vault — and there is no recursion in +// the arrangement that is correct, because the VTA channel signs locally. + +import type { TrustTaskSender } from "../vta/channel.js"; +import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskSigner } from "../vta/trust-task.js"; +import type { Identity } from "../didcomm/index.js"; + +import { vaultSignTrustTask } from "./sign-trust-task.js"; + +export interface VaultTaskSignerOptions { + /** Channel to the **VTA** — not to the party the signed document is for. */ + session: TrustTaskSender; + /** The wallet's holder identity: the issuer of the `vault/sign-trust-task` + * request itself, which the VTA authenticates in the ordinary way. */ + holder: Identity; + /** The VTA's endpoint — the request's `recipient`. */ + service: RemoteDidcommEndpoint; + /** The vault entry holding the persona's key. */ + entryId: string; + /** The persona DID the proof will verify under. Read from the entry's + * `principalDid`, never assumed: it is maintainer-derived, and an entry + * rotated at the VTA signs as something the wallet never chose. */ + did: string; +} + +/** + * Sign outbound documents as a vault entry's persona. + * + * The VTA refuses with `envelope_issuer_mismatch` when the envelope's `issuer` + * is not the entry's `principalDid`, so `did` and the envelope must already + * agree — `signOutboundTask` checks that before calling this, which turns a + * remote refusal into a local error naming both DIDs. + */ +export function vaultTaskSigner(opts: VaultTaskSignerOptions): TaskSigner { + return { + did: opts.did, + sign: async (envelope) => { + const { signedEnvelope } = await vaultSignTrustTask(opts.session, { + holder: opts.holder, + service: opts.service, + entryId: opts.entryId, + unsignedEnvelope: envelope as unknown as Record, + }); + const proof = (signedEnvelope as { proof?: unknown }).proof; + if (!proof) { + // The VTA answered without putting a proof on it. Returning quietly + // would send an unsigned document the RP refuses as `proofRequired`, + // with nothing pointing at the step that dropped it. + throw new Error( + `vault/sign-trust-task: the VTA returned an envelope with no proof for ${opts.did}`, + ); + } + // Mutate in place: `signOutboundTask` returns void because every channel + // sends the envelope it already holds, so a signer that returned a new + // object would have its signature silently discarded. + (envelope as { proof?: unknown }).proof = proof; + }, + }; +} diff --git a/packages/core/src/vta/auth-tasks.ts b/packages/core/src/vta/auth-tasks.ts index 8bf36ac..2128d0c 100644 --- a/packages/core/src/vta/auth-tasks.ts +++ b/packages/core/src/vta/auth-tasks.ts @@ -53,6 +53,15 @@ export type { TokenBundle }; export interface AuthTaskCallerParams { holder: Identity; service: RemoteDidcommEndpoint; + /** + * DID the document is issued by. Defaults to the holder's. + * + * Different only when the channel signs as someone else — a per-site persona, + * whose key lives at the VTA. It MUST match the channel's signer: + * `signOutboundTask` refuses the mismatch locally, which is better than the + * consumer's `identityMismatch` with no hint that the cause is here. + */ + issuer?: string; } export interface AuthChallengeParams extends AuthTaskCallerParams { @@ -72,7 +81,7 @@ export async function requestAuthChallenge( ...(params.purpose ? { purpose: params.purpose } : {}), }; const envelope = buildTrustTask(AUTH_CHALLENGE, payload, { - issuer: params.holder.did, + issuer: params.issuer ?? params.holder.did, recipient: params.service.did, }); return sender.send(envelope, { @@ -117,7 +126,7 @@ export async function authenticateSession( ...(params.scope && params.scope.length > 0 ? { scope: params.scope } : {}), }; const envelope = buildTrustTask(AUTH_AUTHENTICATE, payload, { - issuer: params.holder.did, + issuer: params.issuer ?? params.holder.did, recipient: params.service.did, }); return sender.send(envelope, { @@ -151,7 +160,7 @@ export async function refreshAuthSession( ...(params.scope ? { scope: params.scope } : {}), }; const envelope = buildTrustTask(AUTH_REFRESH, payload, { - issuer: params.holder.did, + issuer: params.issuer ?? params.holder.did, recipient: params.service.did, }); return sender.send(envelope, { @@ -209,7 +218,7 @@ export async function startPasskeyLogin( ...(params.purpose !== undefined ? { purpose: params.purpose } : {}), }; const envelope = buildTrustTask(AUTH_PASSKEY_LOGIN_START, payload, { - issuer: params.holder.did, + issuer: params.issuer ?? params.holder.did, recipient: params.service.did, }); return sender.send(envelope, { @@ -246,7 +255,7 @@ export async function finishPasskeyLogin( credential: params.credential, }; const envelope = buildTrustTask(AUTH_PASSKEY_LOGIN_FINISH, payload, { - issuer: params.holder.did, + issuer: params.issuer ?? params.holder.did, recipient: params.service.did, }); return sender.send(envelope, { diff --git a/packages/core/src/vta/didcomm.ts b/packages/core/src/vta/didcomm.ts index 4c32b4a..674b366 100644 --- a/packages/core/src/vta/didcomm.ts +++ b/packages/core/src/vta/didcomm.ts @@ -16,6 +16,7 @@ import { type TrustTask, } from "./protocol.js"; import { buildTrustTask, parseTrustTaskReply, signOutboundTask } from "./trust-task.js"; +import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js"; import type { SigningIdentity } from "../siop/self-issued.js"; import type { NotifyOpts, SendOpts, TrustTaskChannel } from "./channel.js"; import type { DidcommMessageBridge, VtaTransport } from "./transport.js"; @@ -44,7 +45,7 @@ export interface DidcommVtaTransportOptions { * path forwards whatever it is handed. The proof is what ties the payload to * the DID named in `issuer`. */ - signing: SigningIdentity; + signing: ChannelSigner; /** Optional mediator. When set, every outbound message gets wrapped * in a routing/2.0/forward envelope and anoncrypt'd to the mediator. */ mediator?: RemoteDidcommEndpoint; @@ -71,12 +72,12 @@ export class DidcommVtaTransport implements VtaTransport, TrustTaskChannel { private readonly bridge: DidcommMessageBridge; private readonly holder: Identity; private readonly vta: RemoteDidcommEndpoint; - private readonly signing: SigningIdentity; + private readonly signer: TaskSigner; private readonly mediator?: RemoteDidcommEndpoint; private readonly timeoutMs: number; constructor(opts: DidcommVtaTransportOptions) { - this.signing = opts.signing; + this.signer = asTaskSigner(opts.signing); this.bridge = opts.bridge; this.holder = opts.holder; this.vta = opts.vta; @@ -237,7 +238,7 @@ export class DidcommVtaTransport implements VtaTransport, TrustTaskChannel { // Every outbound path — `send`, `notify`, and the passkey-VM convenience // surface — packs through here, which is why the proof is attached here // and not in each of them. - await signOutboundTask(envelope, this.signing); + await signOutboundTask(envelope, this.signer); const requestId = envelope.id; const message = { id: requestId, diff --git a/packages/core/src/vta/rest-channel.ts b/packages/core/src/vta/rest-channel.ts index 83f155c..f1f0ecd 100644 --- a/packages/core/src/vta/rest-channel.ts +++ b/packages/core/src/vta/rest-channel.ts @@ -16,6 +16,7 @@ import { TRUST_TASK_PATH } from "./endpoint.js"; import { errorFromBody, VtaClientError } from "./errors.js"; import type { TrustTask } from "./protocol.js"; import { parseTrustTaskReply, signOutboundTask } from "./trust-task.js"; +import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js"; import type { SigningIdentity } from "../siop/self-issued.js"; import { isTrustTaskErrorType } from "./protocol.js"; import { getVtaBearer, makeReauth, type VtaAuthInputs } from "./auth.js"; @@ -31,7 +32,7 @@ export interface RestChannelOptions extends VtaAuthInputs { * * Its `did` must be the envelope's `issuer` — see {@link signOutboundTask}. */ - signing: SigningIdentity; + signing: ChannelSigner; /** * Trust-task dispatcher path, appended to `baseUrl`. Defaults to * `/trust-tasks`, which the published HTTPS binding fixes — `baseUrl` is @@ -55,12 +56,12 @@ export interface RestChannelOptions extends VtaAuthInputs { export class RestChannel implements TrustTaskChannel { readonly kind = "rest" as const; private readonly auth: VtaAuthInputs; - private readonly signing: SigningIdentity; + private readonly signer: TaskSigner; private readonly path: string; private readonly fetchImpl: typeof fetch; constructor(opts: RestChannelOptions) { - this.signing = opts.signing; + this.signer = asTaskSigner(opts.signing); this.auth = { baseUrl: opts.baseUrl, holder: opts.holder, @@ -84,7 +85,7 @@ export class RestChannel implements TrustTaskChannel { // Before serialization, and before the bearer handshake: the proof is part // of the document, so a body built from an unsigned envelope would be the // one thing that reaches the VTA. - await signOutboundTask(envelope, this.signing); + await signOutboundTask(envelope, this.signer); const body = JSON.stringify(envelope); const once = async (bearer: string): Promise => { diff --git a/packages/core/src/vta/trust-task.ts b/packages/core/src/vta/trust-task.ts index 68589e7..03c9996 100644 --- a/packages/core/src/vta/trust-task.ts +++ b/packages/core/src/vta/trust-task.ts @@ -105,21 +105,79 @@ export function buildTrustTask

( */ export async function signOutboundTask( envelope: TrustTask, - signing: SigningIdentity, + signer: TaskSigner, ): Promise { // SPEC §7.2 item 6 — the in-band issuer must be the party that signed. A // consumer rejects the mismatch, so catching it here turns a remote // `identityMismatch` into a local error naming both DIDs. - if (envelope.issuer !== undefined && envelope.issuer !== signing.did) { + if (envelope.issuer !== undefined && envelope.issuer !== signer.did) { throw new VtaClientError( "e.client.identity", - `${envelope.type}: envelope issuer ${envelope.issuer} is not the signing identity ${signing.did}`, + `${envelope.type}: envelope issuer ${envelope.issuer} is not the signing identity ${signer.did}`, ); } - await signTrustTask({ - envelope: envelope as unknown as Record & { proof?: unknown }, - signing, - }); + await signer.sign(envelope); +} + +/** + * Whatever can put a proof on an outbound document. + * + * An interface rather than a key, because the wallet does not hold every key it + * needs to sign with. A per-site persona's key lives at the VTA and never + * leaves it, so signing as one is a request, not a computation — but from the + * channel's point of view the two are the same operation, and the RP cannot + * tell them apart either: it verifies a proof against `did`, wherever the + * bytes were produced. + * + * **Still REQUIRED, for the reason above.** Widening the type does not weaken + * the rule that a channel cannot be built without one; a channel with no signer + * would still be a channel that silently sends unsigned documents. What it + * changes is only *where the key is*. + * + * `did` must be the DID the proof will verify under. A signer whose `did` + * disagrees with what it actually signs produces documents that fail at the + * consumer with `identityMismatch`, and the issuer check above cannot catch it + * — it compares the envelope against this field, not against the signature. + */ +export interface TaskSigner { + readonly did: string; + sign(envelope: TrustTask): Promise; +} + +/** A signer backed by a key this process holds — the holder's own identity, + * and what every channel used before the persona paths existed. */ +export function localTaskSigner(signing: SigningIdentity): TaskSigner { + return { + did: signing.did, + sign: async (envelope) => { + await signTrustTask({ + envelope: envelope as unknown as Record & { proof?: unknown }, + signing, + }); + }, + }; +} + +/** + * What a channel accepts for its signing input. + * + * A bare {@link SigningIdentity} is still accepted because the deprecated + * `*Rest` helpers thread one straight through their own options types, and + * changing that would rewrite fourteen public interfaces to say something none + * of their callers need to know. It is normalised on the way in, so exactly one + * shape reaches {@link signOutboundTask}. + * + * This is not a compatibility fold: both arms are live, they describe local + * objects rather than a wire format, and neither is a legacy spelling of the + * other. + */ +export type ChannelSigner = SigningIdentity | TaskSigner; + +/** Normalise a channel's signing input. Call once, at construction — a channel + * that re-normalised per send would re-derive the same object on every + * outbound document. */ +export function asTaskSigner(signer: ChannelSigner): TaskSigner { + return "sign" in signer ? signer : localTaskSigner(signer); } export interface ParseTrustTaskReplyOptions { diff --git a/packages/core/src/vta/tsp-channel.ts b/packages/core/src/vta/tsp-channel.ts index 4f5b146..775441a 100644 --- a/packages/core/src/vta/tsp-channel.ts +++ b/packages/core/src/vta/tsp-channel.ts @@ -25,6 +25,7 @@ import type { NotifyOpts, SendOpts, TrustTaskChannel } from "./channel.js"; import { VtaClientError } from "./errors.js"; import type { TrustTask } from "./protocol.js"; import { parseTrustTaskReply, signOutboundTask } from "./trust-task.js"; +import { asTaskSigner, type ChannelSigner, type TaskSigner } from "./trust-task.js"; import type { SigningIdentity } from "../siop/self-issued.js"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -122,7 +123,7 @@ export interface TspChannelOptions { * over the document, naming a `verificationMethod` a verifier can resolve — * which the outer signature is not and cannot become. */ - signing: SigningIdentity; + signing: ChannelSigner; /** Per-request timeout (default 30s). */ timeoutMs?: number; } @@ -140,11 +141,11 @@ export class TspChannel implements TrustTaskChannel { private readonly transport: TspTransport; private readonly holder: TspHolderIdentity; private readonly vta: TspRemoteEndpoint; - private readonly signing: SigningIdentity; + private readonly signer: TaskSigner; private readonly timeoutMs: number; constructor(opts: TspChannelOptions) { - this.signing = opts.signing; + this.signer = asTaskSigner(opts.signing); this.transport = opts.transport; this.holder = opts.holder; this.vta = opts.vta; @@ -155,7 +156,7 @@ export class TspChannel implements TrustTaskChannel { private async packForVta(envelope: TrustTask): Promise { // Both `send` and `notify` seal through here, so this is the one place the // proof has to be attached — before the JSON the seal is taken over. - await signOutboundTask(envelope, this.signing); + await signOutboundTask(envelope, this.signer); // TSP plaintext = the Trust-Task envelope JSON (no binding wrapper). const plaintext = utf8.encode(JSON.stringify(envelope)); const packed = await pack(plaintext, this.holder.vid, this.vta.vid, { diff --git a/packages/core/tests/rp-login.trust-task.mjs b/packages/core/tests/rp-login.trust-task.mjs index 8e6af0b..154e1d7 100644 --- a/packages/core/tests/rp-login.trust-task.mjs +++ b/packages/core/tests/rp-login.trust-task.mjs @@ -88,19 +88,36 @@ test("a refresh token is omitted rather than invented when the RP sends none", a assert.ok(!("refreshToken" in s) || s.refreshToken === undefined); }); -test("a signing identity that is not the holder is refused before anything is sent", async () => { - // The proof is the authentication, so a signature by another key - // authenticates nobody — and the RP's answer for that is an opaque - // permissionDenied that says nothing about the cause being local. +test("both auth documents are issued by the holder when no subject is named", async () => { + // The guard this replaces asserted "the signing identity must be the + // holder", checked inside `loginViaTrustTask`. It could not survive a + // channel that signs as a per-site persona, and it was checking the wrong + // thing anyway: `loginViaTrustTask` never signs. `signOutboundTask` compares + // the envelope's issuer against the signer's DID on every outbound document, + // which is the same rule enforced where the signature actually happens. + // + // What still has to hold here is that both steps name ONE identity. The RP + // refuses unless the DID it issued the challenge to is the one that signed + // (`session.did != input.signer_did`), so a challenge for A spent by a + // document issued by B fails remotely with nothing pointing here. const sender = rp(); - await assert.rejects( - () => - loginViaTrustTask( - opts(sender, { signing: { did: "did:key:zSomeoneElse", kid: "x", privateKey: new Uint8Array(32) } }), - ), - /is not the holder/, - ); - assert.equal(sender.sent.length, 0, "nothing may reach the RP"); + await loginViaTrustTask(opts(sender)); + assert.equal(sender.sent.length, 2); + assert.equal(sender.sent[0].envelope.issuer, HOLDER); + assert.equal(sender.sent[1].envelope.issuer, HOLDER); + assert.equal(sender.sent[0].envelope.payload.subject, HOLDER); +}); + +test("a named subject issues BOTH documents, not just the challenge", async () => { + // The half that would break silently: request the challenge for the persona + // but issue the authenticate as the holder, and the RP rejects on the signer + // check — after the operator has already approved the sign-in. + const PERSONA = "did:webvh:zScid:agent.example:contexts:personal"; + const sender = rp(); + await loginViaTrustTask(opts(sender, { subject: PERSONA })); + assert.equal(sender.sent[0].envelope.issuer, PERSONA); + assert.equal(sender.sent[0].envelope.payload.subject, PERSONA); + assert.equal(sender.sent[1].envelope.issuer, PERSONA); }); test("a refused challenge surfaces as itself, not as a login failure", async () => { diff --git a/packages/core/tests/vta.outbound-signing.mjs b/packages/core/tests/vta.outbound-signing.mjs index b16c2fb..a5add7f 100644 --- a/packages/core/tests/vta.outbound-signing.mjs +++ b/packages/core/tests/vta.outbound-signing.mjs @@ -30,6 +30,7 @@ import { TRUST_TASK_ENVELOPE_TYPE, buildTrustTask, generateSigningIdentity, + localTaskSigner, signOutboundTask, verifyTrustTaskProof, } from "../dist/index.js"; @@ -220,7 +221,7 @@ test("an envelope whose issuer is not the signer is refused before it is sent", }); await assert.rejects( - () => signOutboundTask(envelope, signing), + () => signOutboundTask(envelope, localTaskSigner(signing)), (err) => { assert.equal(err.code, "e.client.identity"); return true; @@ -251,7 +252,7 @@ test("re-signing a document that already carries a proof does not sign over it", }); envelope.proof = { type: "DataIntegrityProof", proofValue: "zStaleGarbage" }; - await signOutboundTask(envelope, signing); + await signOutboundTask(envelope, localTaskSigner(signing)); await assertSignedBy(envelope, signing.did); }); diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 8106e22..2691a05 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -1070,16 +1070,35 @@ async function handleLoginDidcomm( await pinOrigin(req.origin, req.params.controlDid); } + // Same question as the REST path, same answer: a per-site persona signs the + // auth documents when this origin has one. The transport stays the wallet's + // own — the RP reads the caller off the document's proof, not off who + // delivered it. + const identity = await resolveLoginIdentity(req.origin, req.params.controlDid, holderDid); + if (!identity.ok) return { ok: false, error: identity.error }; + await ensureOffscreenDocument(); - const activeVtaDid = await readActiveVtaDid(); - if (!activeVtaDid) return { ok: false, error: "no active VTA connection — connect first" }; + const active = await readActiveConnection(); + if (!active.ok) return { ok: false, error: active.error }; const offscreenRequest: OffscreenDidcommLoginRequest = { target: OFFSCREEN_TARGET, type: OFFSCREEN_DIDCOMM_LOGIN, - vtaDid: activeVtaDid, + vtaDid: active.conn.vtaDid, params: req.params, + ...(identity.entryId ? { entryId: identity.entryId } : {}), + ...(active.conn.restBaseUrl ? { restBaseUrl: active.conn.restBaseUrl } : {}), }; - return (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; + const result = (await chrome.runtime.sendMessage(offscreenRequest)) as RuntimeLoginResponse; + + if (!result.ok && identity.bound) { + return { + ok: false, + error: + `${result.error} — this was the first sign-in as ${identity.did}. ` + + `If ${req.origin ?? "the site"} refused it, that identity needs to be on its access list.`, + }; + } + return result; } async function handleStepUpVta( diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 5cb61f6..8451467 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1607,6 +1607,13 @@ export interface OffscreenOnboardConnectRequest { /** background → offscreen: run a DIDComm login. Reply is a * [`RuntimeLoginResponse`] via `sendResponse`. */ export interface OffscreenDidcommLoginRequest { + /** The vault entry whose persona signs the auth documents, when this origin + * has one bound. Absent means the wallet's own identity. Resolved in the + * background — see `OffscreenRestLoginRequest.entryId`. */ + entryId?: string; + /** REST base for the VTA session the persona's signatures go through. + * Unused for a holder login. */ + restBaseUrl?: string; target: typeof OFFSCREEN_TARGET; type: typeof OFFSCREEN_DIDCOMM_LOGIN; /** Which VTA's holder identity to authenticate as. Multi-VTA: the diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 2a7a2f1..e7af568 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -17,7 +17,10 @@ import { loginViaTrustTask, loginViaSiop, selfIssuedMinter, + vaultTaskSigner, + type ChannelSigner, type SiopIdTokenMinter, + type TaskSigner, claimInboundDocument, type MediatorConnection, MediatorSessionBridge, @@ -541,7 +544,26 @@ interface VtaSessionHandle { * because the holder it is about to mint does not exist yet. */ interface SessionIdentity { holder: Identity; + /** The wallet's own key. This is the **transport** identity — TSP seals from + * it, the mediator authenticates it — and, by default, what signs outbound + * documents too. */ signing: SigningIdentity; + /** + * Signs the documents, when that is not the holder. + * + * Transport sender and document signer are different things, and the RP + * treats them as different things: it establishes the caller from the proof + * on the document (`session.did != input.signer_did` in vti-common's + * `handle_authenticate`), not from who delivered it. A per-site persona + * login rides the wallet's own transport — there is no second mediator + * session, and the persona has no key here to open one with — while the + * documents are issued by, and signed as, the persona. + * + * Kept out of `signing` deliberately: widening that field would have handed + * a keyless signer to `tspHolderIdentityFromSecret`, which needs the actual + * private key and would have failed at a distance from the cause. + */ + documentSigner?: TaskSigner; } /** How an identity reaches a mediator. @@ -923,6 +945,9 @@ async function buildVtaSession( } = {}, ): Promise { const { holder, signing } = who; + // Documents are signed by the persona when there is one; the transport + // stays the wallet's own either way. + const documentSigner: ChannelSigner = who.documentSigner ?? signing; const restBaseUrl = opts.restBaseUrl; const service = await resolveKeyAgreement(vtaDid); const services = opts.services ?? (await resolveVtaServices(vtaDid)); @@ -949,12 +974,14 @@ async function buildVtaSession( new TspChannel({ transport: new MediatorSessionTspTransport({ connection: conn }), holder: tspHolderIdentityFromSecret(holder.did, signing.privateKey), - // The same Ed25519 key signs the outer TSP envelope (above) and the - // Trust-Task document (here). They are not redundant: the outer - // signature authenticates the *sender of the frame*, and SPEC §7.2 - // item 7 admits no transport substitute for a proof over the - // document itself. - signing, + // Outer TSP envelope and inner Trust-Task proof are separate + // signatures, and not redundant: the outer one authenticates the + // *sender of the frame*, and SPEC §7.2 item 7 admits no transport + // substitute for a proof over the document itself. Which is exactly + // why they can be different keys — a persona login is sent by the + // wallet and issued by the persona, and the consumer reads the + // caller off the document. + signing: documentSigner, vta: vtaTsp, }), ); @@ -992,7 +1019,7 @@ async function buildVtaSession( new DidcommVtaTransport({ bridge, holder, - signing, + signing: documentSigner, vta: service, mediator: conn.mediator, }), @@ -1001,7 +1028,7 @@ async function buildVtaSession( } const rest = restBaseUrl || services.rest?.baseUrl; if (rest) { - channels.push(new RestChannel({ baseUrl: rest, holder, signing, service })); + channels.push(new RestChannel({ baseUrl: rest, holder, signing: documentSigner, service })); // Deliberately `"unknown"`, not `"up"`. A `RestChannel` is built from a // URL without contacting anything, so construction is not evidence — and // a REST channel that turns out to be unreachable fails the caller's @@ -2830,6 +2857,25 @@ async function doRestLogin( * something the wallet never chose, and the challenge must be requested for * whatever actually signs or the RP refuses on `signer_did` mismatch. */ +/** A {@link TaskSigner} for a vault entry's persona, plus the VTA session it + * signs through. The persona DID is read from the entry rather than assumed, + * for the same reason `personaMinter` reads it: `principalDid` is + * maintainer-derived, and the RP checks the signer against the challenge + * subject. */ +async function personaTaskSigner( + vtaDid: string, + restBaseUrl: string | undefined, + entryId: string, +): Promise { + const { session, holder, service } = await getVtaSession(vtaDid, restBaseUrl); + const listed = await vaultList(session, { holder, service }); + const entry = listed.entries.find((e) => e.id === entryId); + if (!entry?.principalDid) { + throw new Error(`vault entry ${entryId} names no persona DID`); + } + return vaultTaskSigner({ session, holder, service, entryId, did: entry.principalDid }); +} + async function personaMinter( vtaDid: string, restBaseUrl: string | undefined, @@ -2885,9 +2931,17 @@ async function doDidcommLogin( }; sw.mark("resolve rp services"); + // A persona signs the documents when this origin has one; the transport + // stays the wallet's own either way. The signer talks to the **VTA** over the + // wallet's own session — a different channel from the RP one being built here + // — because that is where the persona's key lives. + const documentSigner = req.entryId + ? await personaTaskSigner(req.vtaDid, req.restBaseUrl, req.entryId) + : undefined; + const { session } = await buildVtaSession( req.params.controlDid, - { holder: identity, signing }, + { holder: identity, signing, ...(documentSigner ? { documentSigner } : {}) }, (mediatorDid) => getWarmSession(mediatorDid, req.vtaDid), { services }, ); @@ -2904,8 +2958,8 @@ async function doDidcommLogin( const rpSession = await loginViaTrustTask({ sender: session, holder: identity, - signing, service, + ...(documentSigner ? { subject: documentSigner.did } : {}), ...(req.params.scope ? { scope: req.params.scope } : {}), }); sw.mark("authenticate (trust-task)"); @@ -2918,7 +2972,8 @@ async function doDidcommLogin( // a fabricated value would. refreshToken: rpSession.refreshToken ?? "", sessionId: rpSession.sessionId, - holderDid: signing.did, + // The DID the RP authenticated, not the wallet's own — see doRestLogin. + holderDid: documentSigner?.did ?? signing.did, timings: sw.marks, }, };