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,