Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3720,8 +3720,15 @@ async function handleSignInStep(msg: RuntimeSignInStepRequest): Promise<RuntimeS
flowId,
vtaDid: active.conn.vtaDid,
...(active.conn.restBaseUrl ? { restBaseUrl: active.conn.restBaseUrl } : {}),
// From the worker's own record, on the first step only.
...(msg.step === "prepare" ? { link: record.link, origin: record.origin } : {}),
// From the worker's own record, on the first step only — with what the
// page's origin is pinned to, so the flow can show a changed pairing (M5).
...(msg.step === "prepare"
? {
link: record.link,
origin: record.origin,
...(await signInPinnedDid(record.origin)),
}
: {}),
} as OffscreenSignInStepRequest;
const res = (await chrome.runtime.sendMessage(forward)) as RuntimeSignInStepResponse;
// Every refusal reads the same to the member; this is where whoever has to
Expand All @@ -3730,6 +3737,9 @@ async function handleSignInStep(msg: RuntimeSignInStepRequest): Promise<RuntimeS
if (res?.ok && res.result.kind === "failed") {
console.warn("[pnm sign-in] step failed", failureLogFields(msg.step, res.result));
}
if (res.ok && res.result.kind === "done" && res.result.decision === "approve") {
await pinSignInOrigin(record);
}
if (res.ok && (res.result.kind === "done" || res.result.kind === "refused" ||
res.result.kind === "not-member" ||
res.result.kind === "join-elsewhere" ||
Expand All @@ -3739,6 +3749,36 @@ async function handleSignInStep(msg: RuntimeSignInStepRequest): Promise<RuntimeS
return res;
}

/** The relying party a sign-in flow's page origin is pinned to, for `prepare`;
* nothing when the click carried no origin or the origin has no pin. */
async function signInPinnedDid(origin: string | undefined): Promise<{ pinnedDid?: string }> {
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<void> {
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
Expand Down
18 changes: 16 additions & 2 deletions packages/extension/src/bridge-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <name> at <portal origin>?" */
| { kind: "confirm"; communityName: string; portalOrigin: string; identities: SignInIdentityView[] }
/** C3 step 5: "Sign in to <name> at <portal origin>?" `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
Expand Down
8 changes: 8 additions & 0 deletions packages/extension/src/sign-in-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ export class SignInFlows {
restBaseUrl?: string;
link?: string;
origin?: string;
pinnedDid?: string;
} & SignInStep,
): Promise<SignInStepResult> {
if (req.step === "prepare")
Expand All @@ -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") {
Expand Down Expand Up @@ -569,6 +571,7 @@ export class SignInFlows {
origin: string | undefined,
vtaDid: string,
restBaseUrl: string | undefined,
pinnedDid: string | undefined,
): Promise<SignInStepResult> {
if (this.flows.has(flowId))
return {
Expand Down Expand Up @@ -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 } : {}),
};
}

Expand Down
17 changes: 15 additions & 2 deletions packages/extension/src/sign-in-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1482,6 +1484,17 @@ export function SignInView({
<p style={{ margin: 0, color: c.muted }}>
Nothing is sent to the community until you continue.
</p>
{screen.changedFromDid && (
<div role="alert" data-testid="changed-from">
<Note tone="danger">
<strong>This site signed you in to a different community before.</strong>{" "}
Check that <strong style={wrap}>{screen.communityName}</strong> is the one you
expect before continuing.
<div style={{ ...microLabel, padding: 0, marginTop: 6 }}>Previously</div>
<ShortDid value={screen.changedFromDid} />
</Note>
</div>
)}
<fieldset
style={{
border: "none",
Expand Down
15 changes: 15 additions & 0 deletions packages/extension/tests/sign-in-flows.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,21 @@ const step = (w: World, s: Record<string, unknown>, 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 });
Expand Down
20 changes: 20 additions & 0 deletions packages/extension/tests/sign-in-view.render.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SignInStepResult, { kind: "confirm" }>),
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,
Expand Down
Loading