diff --git a/packages/demo-rp/login-harness.mjs b/packages/demo-rp/login-harness.mjs
index 958a16d..7dd8708 100644
--- a/packages/demo-rp/login-harness.mjs
+++ b/packages/demo-rp/login-harness.mjs
@@ -65,6 +65,7 @@ way a relying party would. Keep the wallet's offscreen console open beside this.
+
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
@@ -112,6 +113,25 @@ second click should not prompt for an identity again.
}
});
+ // 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
diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts
index eec06a1..d26e605 100644
--- a/packages/extension/src/background.ts
+++ b/packages/extension/src/background.ts
@@ -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,
@@ -159,6 +160,8 @@ import {
type RuntimeVaultReleaseRequest,
type RuntimeVaultReleaseResponse,
type ProxyLoginParams,
+ type RuntimeWalletProfileRequest,
+ type RuntimeWalletProfileResponse,
type RuntimeVaultUpsertRequest,
type RuntimeVaultUpsertResponse,
type RuntimeApproverStateResponse,
@@ -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) {
@@ -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 {
+ 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(
@@ -1918,18 +2010,20 @@ 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,
@@ -1937,11 +2031,11 @@ async function resolveProfileEntry(
// 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 } : {}) };
}
@@ -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)
diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts
index e4ae453..98a935e 100644
--- a/packages/extension/src/bridge-protocol.ts
+++ b/packages/extension/src/bridge-protocol.ts
@@ -30,6 +30,7 @@ export type BridgeMethod =
| "walletDefaults"
| "signTrustTask"
| "proxyLogin"
+ | "walletProfile"
| "vaultList"
| "requestTask";
@@ -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
@@ -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;
@@ -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;
diff --git a/packages/extension/src/content.ts b/packages/extension/src/content.ts
index d13c4a7..af09e1a 100644
--- a/packages/extension/src/content.ts
+++ b/packages/extension/src/content.ts
@@ -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";
@@ -61,6 +62,7 @@ const RUNTIME_TYPE_BY_METHOD: Record = {
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,
};
diff --git a/packages/extension/src/provider.ts b/packages/extension/src/provider.ts
index c98e484..a757aa8 100644
--- a/packages/extension/src/provider.ts
+++ b/packages/extension/src/provider.ts
@@ -4,6 +4,7 @@
// `window.postMessage` using the bridge protocol.
import type {
+ BridgeMethod,
RequestTaskParams,
ApiGetParams,
ApiGetResult,
@@ -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
@@ -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;
+ /** 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;
/** 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.
@@ -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(
- method:
- | "login"
- | "loginDidcomm"
- | "stepUpVta"
- | "apiGet"
- | "apiPost"
- | "mediatorStatus"
- | "walletDefaults"
- | "signTrustTask"
- | "proxyLogin"
- | "vaultList"
- | "requestTask",
+ method: BridgeMethod,
params:
| LoginParams
| DidcommLoginParams
@@ -161,6 +175,7 @@ function call(
| ApiPostParams
| SignTrustTaskParams
| ProxyLoginParams
+ | WalletProfileParams
| VaultListParams
| RequestTaskParams
| Record,
@@ -185,6 +200,7 @@ if (!window.vtaWallet) {
walletDefaults: () => call("walletDefaults", {}),
signTrustTask: (params) => call("signTrustTask", params),
proxyLogin: (params) => call("proxyLogin", params),
+ walletProfile: (params) => call("walletProfile", params),
vaultList: (params) => call("vaultList", params),
requestTask: (params) => call>("requestTask", params),
};
diff --git a/packages/extension/tests/page-facing-surface.test.mts b/packages/extension/tests/page-facing-surface.test.mts
new file mode 100644
index 0000000..11d6bcc
--- /dev/null
+++ b/packages/extension/tests/page-facing-surface.test.mts
@@ -0,0 +1,111 @@
+// The content script's copy of the protocol, checked against the protocol.
+//
+// `content.ts` is injected as a *classic* script and cannot `import`, so it
+// inlines the runtime-type strings with a "keep these in sync" comment and no
+// guard. Two things ride on that hand-sync, and neither fails loudly:
+//
+// 1. **Origin attestation.** `background.ts` overrides the body's origin with
+// the browser's `sender` origin for types in `PAGE_FACING_RUNTIME_TYPES`,
+// and only those. A page-facing method routed to a type missing from that
+// list would let the calling page name its own origin — and every vault
+// entry, trust record and pin in this wallet is keyed on origin.
+//
+// 2. **A typo routes nowhere.** A mistyped constant produces a message the
+// background has no branch for, so `sendMessage` resolves `undefined` and
+// the page sees a shapeless failure with nothing pointing here.
+//
+// Neither is hypothetical: `provider.ts` inlined a second copy of the
+// `BridgeMethod` union, and adding a method broke at its call site with an
+// error naming every method but the new one. This reads the sources rather
+// than importing them, because `content.ts` touches `chrome` at module scope.
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+
+const src = (name: string) =>
+ readFileSync(fileURLToPath(new URL(`../src/${name}`, import.meta.url)), "utf8");
+
+const content = src("content.ts");
+const protocol = src("bridge-protocol.ts");
+
+/** `const NAME = "value"` / `export const NAME = "value" as const` → a map.
+ *
+ * The value may sit on the following line: a long declaration is wrapped by
+ * the formatter, and a same-line-only pattern reported the first such constant
+ * as missing from bridge-protocol.ts entirely. A parse gap that reads as a
+ * drift is worse than no test, so `\s*` spans the break. */
+function constants(source: string): Map {
+ const out = new Map();
+ const re = /^(?:export )?const (RUNTIME_[A-Z0-9_]+) =\s*"([^"]+)"/gm;
+ for (const m of source.matchAll(re)) out.set(m[1]!, m[2]!);
+ return out;
+}
+
+/** The identifiers listed in a `const NAME = [ … ] as const` array. */
+function arrayMembers(source: string, name: string): string[] {
+ const m = new RegExp(`const ${name} = \\[([^\\]]*)\\]`).exec(source);
+ assert.ok(m, `${name} not found — this test is reading the wrong shape`);
+ return m[1]!
+ .split(",")
+ .map((s) => s.replace(/\/\/.*$/gm, "").trim())
+ .filter(Boolean);
+}
+
+/** `method: RUNTIME_CONST,` pairs from the content script's routing table. */
+function routingTable(): Map {
+ const m = /RUNTIME_TYPE_BY_METHOD: Record = \{([^}]*)\}/.exec(content);
+ assert.ok(m, "RUNTIME_TYPE_BY_METHOD not found — this test is reading the wrong shape");
+ const out = new Map();
+ for (const line of m[1]!.split("\n")) {
+ const pair = /^\s*(\w+):\s*(RUNTIME_[A-Z0-9_]+),/.exec(line);
+ if (pair) out.set(pair[1]!, pair[2]!);
+ }
+ return out;
+}
+
+const contentConsts = constants(content);
+const protocolConsts = constants(protocol);
+const table = routingTable();
+
+test("the routing table is not empty", () => {
+ // Every assertion below is vacuously true against an empty table, which is
+ // exactly how a regex that stopped matching would pass silently.
+ assert.ok(table.size >= 10, `routing table has only ${table.size} entries`);
+ assert.ok(contentConsts.size >= 10, `parsed only ${contentConsts.size} constants`);
+});
+
+test("every constant the content script inlines matches bridge-protocol", () => {
+ for (const [name, value] of contentConsts) {
+ const canonical = protocolConsts.get(name);
+ assert.ok(canonical, `content.ts declares ${name}, which bridge-protocol.ts does not`);
+ assert.equal(
+ value,
+ canonical,
+ `${name} has drifted: content.ts says "${value}", bridge-protocol.ts says "${canonical}"`,
+ );
+ }
+});
+
+test("every page-facing method routes to an origin-attested type", () => {
+ const pageFacing = new Set(arrayMembers(protocol, "PAGE_FACING_RUNTIME_TYPES"));
+ for (const [method, constName] of table) {
+ assert.ok(
+ pageFacing.has(constName),
+ `window.vtaWallet.${method}() routes to ${constName}, which is absent from ` +
+ `PAGE_FACING_RUNTIME_TYPES — the background would take that call's origin ` +
+ `from the page's own message body instead of from the browser`,
+ );
+ }
+});
+
+test("every method in the BridgeMethod union has a route", () => {
+ const union = /export type BridgeMethod =([\s\S]*?);/.exec(protocol);
+ assert.ok(union, "BridgeMethod union not found");
+ const methods = [...union[1]!.matchAll(/"(\w+)"/g)].map((m) => m[1]!);
+ assert.ok(methods.length >= 10, `parsed only ${methods.length} methods`);
+ for (const method of methods) {
+ assert.ok(table.has(method), `BridgeMethod "${method}" has no entry in RUNTIME_TYPE_BY_METHOD`);
+ }
+});