From 762967b16ffc4120686048de76ea37ad297dd0e4 Mon Sep 17 00:00:00 2001 From: Geoff Turk Date: Fri, 23 Oct 2026 15:36:09 +0200 Subject: [PATCH] fix(sign-in): pin the page's origin on an approved trigger-link sign-in, so a console can set up signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A trigger-link sign-in (auth/oob/*) is now the first option on a VTC operator console's login page, and the only wallet sign-in that seeded no origin → relying-party pin (origin-pin.ts, M5). signTrustTask is bound to that pin and never seeds one, so an administrator who signed in that way was refused "has no relying party pinned" at Set up signing, and could not enrol a console key at all. Fixes #377. An approved grant now pins the click's browser-attested origin to the community the link names, as the page-bridge logins pin theirs on approval: handleSignInStep does it on a done/approve, from the flow record (the origin the flow refused unless it was the portal the community's DID document names, VTI-LNK-105) and the link's `from`, parsed with now: 0 as the page-number relay does, so a link that lapsed while the person approved still names its community. The pin's other half is kept: a page that asks for a different relying party than it was pinned to gets a loud prompt. On prepare the background forwards what the origin is pinned to (pinnedDid); the flow adds changedFromDid to the confirm screen when the link's community differs; the window shows a danger note naming the previous community and no longer skips the confirm screen for a single identity in that case, so the warning is seen before anything is sent. bridge-protocol: pinnedDid on the offscreen prepare request, changedFromDid on the confirm result; both optional. page-task-policy is unchanged: no pin still means no signature. Signed-off-by: Geoff Turk --- packages/extension/src/background.ts | 44 ++++++++++++++++++- packages/extension/src/bridge-protocol.ts | 18 +++++++- packages/extension/src/sign-in-flows.ts | 8 ++++ packages/extension/src/sign-in-view.tsx | 17 ++++++- .../extension/tests/sign-in-flows.test.mts | 15 +++++++ .../tests/sign-in-view.render.test.mts | 20 +++++++++ 6 files changed, 116 insertions(+), 6 deletions(-) diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index ed3deeb..ee67be9 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -3720,8 +3720,15 @@ async function handleSignInStep(msg: RuntimeSignInStepRequest): Promise { + if (!origin) return {}; + const pin = await readOriginPin(origin); + return pin ? { pinnedDid: pin.rpDid } : {}; +} + +/** + * An approved trigger-link sign-in pins the page's origin to the community it + * signed in to, as the page-bridge logins pin theirs on approval (`origin-pin.ts`, + * M5). The pin is what the page's later `signTrustTask` is bound to + * (`pageSignBindingRefusal`): without it, a console that signed its operator in + * this way could never enrol a signing key, since nothing but a login seeds a + * pin (#377). + * + * The pairing is the one the flow verified: `record.origin` is the browser's + * word for where the click came from, the flow refused unless that is the + * portal the community's own DID document names (VTI-LNK-105), and the link's + * `from` is the community it signed in to. Parsed with `now: 0`, as + * `handleTriggerLinkNumber` does — the flow has already judged expiry, and a + * link that lapsed while the person approved still names the community. + */ +async function pinSignInOrigin(record: SignInFlowRecord): Promise { + if (!record.origin) return; + const parsed = parseTriggerLink(record.link, { now: 0 }); + if (!parsed.ok) return; + await pinOrigin(record.origin, parsed.link.from.value); +} + chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { // Defence-in-depth: only accept messages from this extension's diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 70e0910..25af23a 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -2896,6 +2896,11 @@ export type OffscreenSignInStepRequest = { /** Present on `prepare` only, from the background's own record. */ link?: string; origin?: string; + /** Present on `prepare` only, when `origin` is pinned to a relying party + * (`origin-pin.ts`, M5): that DID. The flow names it on the confirm screen + * when the link's community is a different one, so a changed pairing gets + * the loud prompt the page-bridge logins give it. */ + pinnedDid?: string; } & SignInStep; /** How this browser approves sign-ins at an agent: the kind of UV key the @@ -2990,8 +2995,17 @@ export type SignInStepResult = * wants more evidence (`requestMore`, `needs`). The flow has ended; no * sign-in document was sent. */ | { kind: "join-pending"; host: string; portalOrigin: string; needs?: string[] } - /** C3 step 5: "Sign in to at ?" */ - | { kind: "confirm"; communityName: string; portalOrigin: string; identities: SignInIdentityView[] } + /** C3 step 5: "Sign in to at ?" `changedFromDid` is + * the community this origin was pinned to by an earlier sign-in, when the + * link names a different one (M5): the window warns, and does not skip the + * screen for a single identity. */ + | { + kind: "confirm"; + communityName: string; + portalOrigin: string; + identities: SignInIdentityView[]; + changedFromDid?: string; + } /** After the claim: type the number. `nameMismatch` is the community's own * name for itself when it differs from this wallet's record (VTI-LNK-104). * `audience` is the session the website asked for, from the community's diff --git a/packages/extension/src/sign-in-flows.ts b/packages/extension/src/sign-in-flows.ts index c0d33bc..7911816 100644 --- a/packages/extension/src/sign-in-flows.ts +++ b/packages/extension/src/sign-in-flows.ts @@ -475,6 +475,7 @@ export class SignInFlows { restBaseUrl?: string; link?: string; origin?: string; + pinnedDid?: string; } & SignInStep, ): Promise { if (req.step === "prepare") @@ -484,6 +485,7 @@ export class SignInFlows { req.origin, req.vtaDid, req.restBaseUrl, + req.pinnedDid, ); const flow = this.flows.get(req.flowId); if (!flow || flow.phase === "ended") { @@ -569,6 +571,7 @@ export class SignInFlows { origin: string | undefined, vtaDid: string, restBaseUrl: string | undefined, + pinnedDid: string | undefined, ): Promise { if (this.flows.has(flowId)) return { @@ -721,6 +724,11 @@ export class SignInFlows { communityName, portalOrigin: selected.services.portalOrigin, identities, + // The page was signed in to another community before (M5). The pairing + // was verified above all the same — the document names this origin as + // its portal — but the person decides whether that is the community they + // meant, on a screen that says which one it was. + ...(pinnedDid !== undefined && pinnedDid !== vtcDid ? { changedFromDid: pinnedDid } : {}), }; } diff --git a/packages/extension/src/sign-in-view.tsx b/packages/extension/src/sign-in-view.tsx index a340ffd..6367cd7 100644 --- a/packages/extension/src/sign-in-view.tsx +++ b/packages/extension/src/sign-in-view.tsx @@ -774,9 +774,11 @@ export function SignInView({ setEntryId(res.result.identities[0]?.entryId ?? ""); // One identity is no choice: clicking the code on the verified portal // was the person's go-ahead, so the claim goes out without a Continue - // (contract C3 step 6, as amended). With more than one, they pick. + // (contract C3 step 6, as amended). With more than one, they pick. A + // page that signed in to a different community before is shown either + // way: the warning is the point of the screen then (M5). const only = - res.result.identities.length === 1 + res.result.identities.length === 1 && !res.result.changedFromDid ? res.result.identities[0]! : undefined; if (only) { @@ -1482,6 +1484,17 @@ export function SignInView({

Nothing is sent to the community until you continue.

+ {screen.changedFromDid && ( +
+ + This site signed you in to a different community before.{" "} + Check that {screen.communityName} is the one you + expect before continuing. +
Previously
+ +
+
+ )}
, flowId = "f1") => const PRF = new Uint8Array(32).fill(7); const PRF_B64U = bytesToBase64url(PRF); +test("a page pinned to another community is told so on the confirm screen (M5)", async () => { + // The background says what the click's origin is pinned to, from an earlier + // sign-in there. The same community is nothing to report; another one is — + // the pairing was still verified against the document, so the flow goes on, + // but the screen must say which community it was. + const same = await step(world(), { step: "prepare", link: LINK, origin: PORTAL, pinnedDid: VTC }); + assert.equal(same.kind, "confirm"); + assert.equal("changedFromDid" in same, false); + + const other = "did:webvh:QmOther:members.example.org"; + const changed = await step(world(), { step: "prepare", link: LINK, origin: PORTAL, pinnedDid: other }); + assert.equal(changed.kind, "confirm"); + assert.equal((changed as { changedFromDid?: string }).changedFromDid, other); +}); + test("the whole approval, in the contract's order", async () => { const w = world(); const confirm = await step(w, { step: "prepare", link: LINK, origin: PORTAL }); diff --git a/packages/extension/tests/sign-in-view.render.test.mts b/packages/extension/tests/sign-in-view.render.test.mts index 08a33ea..c8fa2f6 100644 --- a/packages/extension/tests/sign-in-view.render.test.mts +++ b/packages/extension/tests/sign-in-view.render.test.mts @@ -184,6 +184,26 @@ test("one identity and the page's number: straight from the code to the review", await screen.unmount(); }); +test("one identity, but the page was pinned to another community: the screen is shown, with the warning", async () => { + const { steps, send } = script({ + ...RESULTS, + prepare: { + ...(RESULTS.prepare as Extract), + identities: [{ entryId: "e1", did: "did:web:alice.example", label: "Alice" }], + changedFromDid: "did:webvh:QmOther:members.example.org", + }, + }); + const screen = await render( + h(SignInView, { flowId: "f", send, uv: ENROLLED_UV, close: () => {} }), + ); + await screen.settle(); + assert.deepEqual(steps, [{ step: "prepare" }], "no claim goes out on its own"); + assert.ok(screen.container.querySelector('[data-testid="continue"]'), "the person continues, or not"); + assert.ok(screen.container.querySelector('[data-testid="changed-from"]')); + assert.match(screen.text(), /signed you in to a different community before/); + await screen.unmount(); +}); + test("one identity, no number from the page: the number screen, without the identity screen", async () => { const { steps, send } = script({ ...RESULTS,