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
20 changes: 20 additions & 0 deletions packages/demo-rp/login-harness.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ way a relying party would. Keep the wallet's offscreen console open beside this.
<button id="didcomm">Login over DIDComm</button>
<button id="siop">Login over REST (SIOP)</button>
<button id="proxy">Login as a per-site persona (proxy SIOP)</button>
<button id="profile">Which identity does this site know me as?</button>

<p class="muted">The proxy button names no vault entry, which is the point: the wallet resolves
one from this origin, and on a first visit asks which identity to use and binds the answer. A
Expand Down Expand Up @@ -112,6 +113,25 @@ second click should not prompt for an identity again.</p>
}
});

// The shape an RP uses when its challenge is bound to the persona DID: ask
// the wallet who this site knows you as, THEN fetch a challenge for that DID,
// then mint. Here it just reports the answer — the harness has no challenge
// endpoint of its own — which is enough to see the first-use prompt fire and
// to confirm a second click does not prompt again.
document.getElementById("profile").addEventListener("click", async () => {
if (!probe()) return;
show(
"Asking the wallet…",
"On a first visit it should ask which identity to bind to this site.",
);
try {
const r = await window.vtaWallet.walletProfile({});
show(r.bound ? "walletProfile bound a new identity" : "walletProfile resolved", r);
} catch (e) {
show("walletProfile rejected", String(e && e.message ? e.message : e));
}
});

// Deliberately passes NO entryId. A page that supplies one has had to call
// vaultList() to learn it — a second consent prompt that enumerates the
// user's vault to this site. Omitting it is the shape a relying party should
Expand Down
121 changes: 112 additions & 9 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
RUNTIME_VAULT_LIST_PAGE,
RUNTIME_VAULT_PROXY_LOGIN,
RUNTIME_VAULT_PROXY_LOGIN_PAGE,
RUNTIME_WALLET_PROFILE,
RUNTIME_VAULT_RELEASE,
RUNTIME_VAULT_UPSERT,
OFFSCREEN_LOCK_WALLET,
Expand Down Expand Up @@ -159,6 +160,8 @@ import {
type RuntimeVaultReleaseRequest,
type RuntimeVaultReleaseResponse,
type ProxyLoginParams,
type RuntimeWalletProfileRequest,
type RuntimeWalletProfileResponse,
type RuntimeVaultUpsertRequest,
type RuntimeVaultUpsertResponse,
type RuntimeApproverStateResponse,
Expand Down Expand Up @@ -1839,7 +1842,7 @@ async function handleVaultProxyLoginPage(
// Resolve the entry BEFORE prompting. Which prompt to raise depends on
// whether this site already has a persona bound, and a page that calls this
// with no VTA connected should fail without raising one at all.
const resolved = await resolveProfileEntry(req);
const resolved = await resolveProfileEntry(req.origin, req.params.entryId);
if (!resolved.ok) return { ok: false, error: resolved.error };

if (resolved.entryId) {
Expand Down Expand Up @@ -1898,6 +1901,95 @@ async function handleVaultProxyLoginPage(
return result;
}

/**
* Which persona this site knows the user as — resolve, or bind one.
*
* Split out of the sign-in because an RP whose `/auth/challenge` is bound to
* the persona DID needs that DID *before* it can ask for a nonce, and so cannot
* reach it through `proxyLogin` at all. The route it had was `vaultList()`,
* which discloses every entry to answer a question about one.
*
* ## Two prompts on a first sign-in, and why that is the right number
*
* A page that binds and then signs in raises the picker here and the sign-in
* consent in `handleVaultProxyLoginPage` — two decisions the first time, one
* every time after. Folding the second into the first would mean this call,
* which mints nothing and issues no session, silently pre-authorizing one that
* does. First contact with a site is the place to ask twice; every later
* sign-in is a single prompt, and the operator can still tick "remember".
*
* Nothing is minted here, and no session is issued. The result is a DID the
* site is about to be told anyway, and the id of the entry holding it.
*/
async function handleWalletProfile(
req: RuntimeWalletProfileRequest,
): Promise<RuntimeWalletProfileResponse> {
const target = req.params.target as { kind?: string; did?: string } | undefined;
const targetDid = target?.kind === "did" ? target.did : undefined;

const resolved = await resolveProfileEntry(req.origin);
if (!resolved.ok) return { ok: false, error: resolved.error };

if (resolved.entryId) {
// Already bound. No prompt: this discloses one DID, to the site that DID
// exists for, which is about to receive it inside an id_token anyway. A
// prompt here would be asking the operator to re-approve a decision they
// already made, which is how prompts stop being read.
const did = await principalDidFor(req.origin, resolved.entryId);
if (!did.ok) return { ok: false, error: did.error };
return { ok: true, result: { did: did.did, entryId: resolved.entryId, bound: false } };
}

// `requestConsent`, not `gatedConsent` — see handleVaultProxyLoginPage. A
// remembered origin has consented to being signed in as an identity already
// chosen, never to a new one being chosen for it.
const decision = await requestConsent({
origin: req.origin,
action: "Choose the identity this site knows you as",
chooseProfile: true,
...(targetDid ? { rpDid: targetDid } : {}),
});
if (!decision.approved || !decision.selectedDid) {
return { ok: false, error: "identity selection denied by user" };
}

const bound = await bindProfileEntry(req.origin, decision.selectedDid, targetDid);
if (!bound.ok) return { ok: false, error: bound.error };
if (decision.remember) await trustOrigin(req.origin, targetDid);

return {
ok: true,
result: { did: decision.selectedDid, entryId: bound.entryId, bound: true },
};
}

/**
* The persona DID an already-bound entry acts as.
*
* `principalDid` is maintainer-derived, so it is read back from the VTA rather
* than reconstructed here: the wallet seals the secret and never sees it again,
* and an entry whose secret was rotated at the VTA would otherwise report a DID
* it no longer signs as.
*/
async function principalDidFor(
origin: string,
entryId: string,
): Promise<{ ok: true; did: string } | { ok: false; error: string }> {
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 entry = listed.result.entries.find((e) => e.id === entryId);
if (!entry?.principalDid) {
// An entry with no principalDid cannot mint an id_token, so returning it
// would hand the page a DID-shaped hole that fails at `/auth/challenge`
// with nothing pointing back here.
return { ok: false, error: `vault entry ${entryId} names no persona DID` };
}
return { ok: true, did: entry.principalDid };
}

/** Send a resolved proxy-login to the offscreen document, where the holder
* identity and the DIDComm unpacking live. */
async function dispatchProxyLogin(
Expand All @@ -1918,30 +2010,32 @@ async function dispatchProxyLogin(
/**
* Which vault entry a page-initiated proxy login should use.
*
* `entryId` supplied by the page is honoured as-is — that is the pre-existing
* contract, and a page that discovered an id through `vaultList()` has already
* had its own consent prompt for it. Otherwise the entry comes from the origin
* the *browser* attested, never from anything the page said about itself.
* `suppliedEntryId` is honoured as-is — that is the pre-existing contract, and
* an id the page holds came either from `vaultList()` (which had its own
* consent prompt) or from `walletProfile()` (which handed back this site's own
* entry). Otherwise the entry comes from the origin the *browser* attested,
* never from anything the page said about itself.
*
* `entryId: undefined` with `ok: true` means "this site has no persona yet",
* which is a first-use prompt, not an error.
*/
async function resolveProfileEntry(
req: RuntimeVaultProxyLoginPageRequest,
origin: string,
suppliedEntryId?: string,
): Promise<{ ok: true; entryId?: string } | { ok: false; error: string }> {
if (req.params.entryId) return { ok: true, entryId: req.params.entryId };
if (suppliedEntryId) return { ok: true, entryId: suppliedEntryId };

const listed = await handleVaultList({
type: RUNTIME_VAULT_LIST,
// `targetOriginPrefix` narrows the set the VTA sends back; it does NOT
// decide the answer. A prefix is not an origin — `https://example.com` is a
// prefix of `https://example.com.evil.test` — so `matchProfileEntry` does
// the actual match locally, with `===`, on the attested origin.
filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin },
filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: origin },
});
if (!listed.ok) return { ok: false, error: listed.error };

const match = matchProfileEntry(listed.result.entries, req.origin);
const match = matchProfileEntry(listed.result.entries, origin);
return { ok: true, ...(match ? { entryId: match.id } : {}) };
}

Expand Down Expand Up @@ -2329,6 +2423,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}

if ((message as { type?: string })?.type === RUNTIME_WALLET_PROFILE) {
handleWalletProfile(message as RuntimeWalletProfileRequest)
.then(sendResponse)
.catch((e: unknown) =>
sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }),
);
return true; // async sendResponse
}

if ((message as { type?: string })?.type === RUNTIME_VAULT_PROXY_LOGIN_PAGE) {
handleVaultProxyLoginPage(message as RuntimeVaultProxyLoginPageRequest)
.then(sendResponse)
Expand Down
48 changes: 48 additions & 0 deletions packages/extension/src/bridge-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type BridgeMethod =
| "walletDefaults"
| "signTrustTask"
| "proxyLogin"
| "walletProfile"
| "vaultList"
| "requestTask";

Expand Down Expand Up @@ -266,6 +267,16 @@ export const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task" as const;
* attested origin, so the wallet never attests to a document the page wrote. */
export const RUNTIME_REQUEST_TASK = "vta-wallet/request-task" as const;

/** page → background: which persona this site knows the user as.
*
* Resolve-or-bind, and it mints nothing. An RP whose challenge is bound to the
* persona DID — the shape both `did-hosting` and `vtc-service` use — needs the
* DID *before* it can ask for a nonce, so it cannot get there through
* `proxyLogin` alone. The alternative it had was `vaultList()`, which
* enumerates the user's whole vault to the site to answer a question about one
* entry. This answers that question and only that one. */
export const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile" as const;

export const RUNTIME_CONSENT_RESULT = "vta-wallet/consent-result" as const;
/** offscreen → background: an inbound, executor-signed `task-consent/request`
* needs a human. Unlike the generic login consent prompt, the surface renders
Expand Down Expand Up @@ -1246,6 +1257,42 @@ export interface ProxyLoginParams {
ttlSecondsHint?: number;
}

/** Page-world params for `window.vtaWallet.walletProfile(...)`. */
export interface WalletProfileParams {
/** The relying party this is for, when the page has a DID for itself. Bound
* as a second target on a newly created entry so the RP's own
* `vaultList({ targetDid })` finds it; never used to *match* an entry, since
* only the origin is browser-attested. */
target?: VaultEntryView["targets"][number];
}

export interface WalletProfileResult {
/** The persona DID this site knows the user as — the `iss`/`sub` of any SIOP
* id_token minted for it, and the DID an RP binds its challenge to. */
did: string;
/** The vault entry backing it. Pass straight to `proxyLogin` so it does not
* repeat the lookup this call just did. Naming an entry the wallet handed
* back for this site costs nothing — the disclosure this avoids was
* `vaultList()` returning every *other* entry too. */
entryId: string;
/** True when this call bound the persona rather than finding one already
* bound, i.e. the operator was prompted. A page can use it to explain why
* the sign-in that follows may be refused until the DID is on its ACL. */
bound: boolean;
}

export type RuntimeWalletProfileResponse =
| { ok: true; result: WalletProfileResult }
| { ok: false; error: string };

export interface RuntimeWalletProfileRequest {
type: typeof RUNTIME_WALLET_PROFILE;
params: WalletProfileParams;
/** Origin of the calling page, captured by the content script. The entry is
* resolved and bound against this, never against anything the page says. */
origin: string;
}

export interface RuntimeVaultProxyLoginPageRequest {
type: typeof RUNTIME_VAULT_PROXY_LOGIN_PAGE;
params: ProxyLoginParams;
Expand Down Expand Up @@ -1685,6 +1732,7 @@ export const PAGE_FACING_RUNTIME_TYPES = [
RUNTIME_WALLET_DEFAULTS,
RUNTIME_SIGN_TRUST_TASK,
RUNTIME_VAULT_PROXY_LOGIN_PAGE,
RUNTIME_WALLET_PROFILE,
RUNTIME_VAULT_LIST_PAGE,
RUNTIME_REQUEST_TASK,
] as const;
Expand Down
2 changes: 2 additions & 0 deletions packages/extension/src/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const RUNTIME_MEDIATOR_STATUS = "vta-wallet/mediator-status";
const RUNTIME_WALLET_DEFAULTS = "vta-wallet/wallet-defaults";
const RUNTIME_SIGN_TRUST_TASK = "vta-wallet/sign-trust-task";
const RUNTIME_VAULT_PROXY_LOGIN_PAGE = "vta-wallet/vault-proxy-login-page";
const RUNTIME_WALLET_PROFILE = "vta-wallet/wallet-profile";
const RUNTIME_VAULT_LIST_PAGE = "vta-wallet/vault-list-page";
const RUNTIME_REQUEST_TASK = "vta-wallet/request-task";
const RUNTIME_BROADCAST_EVENT = "vta-wallet/broadcast-event";
Expand Down Expand Up @@ -61,6 +62,7 @@ const RUNTIME_TYPE_BY_METHOD: Record<BridgeMethod, string> = {
walletDefaults: RUNTIME_WALLET_DEFAULTS,
signTrustTask: RUNTIME_SIGN_TRUST_TASK,
proxyLogin: RUNTIME_VAULT_PROXY_LOGIN_PAGE,
walletProfile: RUNTIME_WALLET_PROFILE,
vaultList: RUNTIME_VAULT_LIST_PAGE,
requestTask: RUNTIME_REQUEST_TASK,
};
Expand Down
40 changes: 28 additions & 12 deletions packages/extension/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// `window.postMessage` using the bridge protocol.

import type {
BridgeMethod,
RequestTaskParams,
ApiGetParams,
ApiGetResult,
Expand All @@ -21,6 +22,8 @@ import type {
VaultListResultView,
VaultProxyLoginResultView,
WalletDefaultsResult,
WalletProfileParams,
WalletProfileResult,
} from "./bridge-protocol.js";

// Bundled as a standalone page-world script, so it must be self-contained
Expand Down Expand Up @@ -87,6 +90,24 @@ interface VtaWallet {
* to learn one, which costs a second consent prompt and shows this site the
* rest of the user's vault. */
proxyLogin(params: ProxyLoginParams): Promise<VaultProxyLoginResultView>;
/** Which persona this site knows the user as, resolving or binding one.
*
* Mints nothing and issues no session — it answers a question. On a site
* with no persona bound the user is asked to pick one and it is remembered;
* after that this is a lookup.
*
* For an RP whose `/auth/challenge` is bound to the persona DID, this is the
* first call: the DID it returns is what the challenge is requested for, and
* the `entryId` goes straight into `proxyLogin` so the wallet does not
* repeat the lookup.
*
* const { did, entryId } = await wallet.walletProfile({ target });
* const { challenge } = await postChallenge(did);
* await wallet.proxyLogin({ entryId, nonce: challenge, target });
*
* `bound: true` means the persona was just created, so the relying party has
* never seen it and may refuse the sign-in until it is on its access list. */
walletProfile(params: WalletProfileParams): Promise<WalletProfileResult>;
/** Enumerate vault entries (metadata only, no secret material) via
* vault/list/0.1. Each returned entry's `principalDid` is the DID the entry
* would act AS when used in a proxy-login call.
Expand Down Expand Up @@ -140,19 +161,12 @@ window.addEventListener("message", (event: MessageEvent) => {
else entry.reject(new Error(data.error));
});

// `BridgeMethod`, not a copy of it. This used to inline the same union, and the
// copy silently went stale the moment a method was added — the union grew, this
// did not, and the new method failed to typecheck at its own call site with an
// error naming every method *but* the one being added.
function call<T>(
method:
| "login"
| "loginDidcomm"
| "stepUpVta"
| "apiGet"
| "apiPost"
| "mediatorStatus"
| "walletDefaults"
| "signTrustTask"
| "proxyLogin"
| "vaultList"
| "requestTask",
method: BridgeMethod,
params:
| LoginParams
| DidcommLoginParams
Expand All @@ -161,6 +175,7 @@ function call<T>(
| ApiPostParams
| SignTrustTaskParams
| ProxyLoginParams
| WalletProfileParams
| VaultListParams
| RequestTaskParams
| Record<string, never>,
Expand All @@ -185,6 +200,7 @@ if (!window.vtaWallet) {
walletDefaults: () => call<WalletDefaultsResult>("walletDefaults", {}),
signTrustTask: (params) => call<SignTrustTaskResult>("signTrustTask", params),
proxyLogin: (params) => call<VaultProxyLoginResultView>("proxyLogin", params),
walletProfile: (params) => call<WalletProfileResult>("walletProfile", params),
vaultList: (params) => call<VaultListResultView>("vaultList", params),
requestTask: (params) => call<Record<string, unknown>>("requestTask", params),
};
Expand Down
Loading
Loading