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
46 changes: 46 additions & 0 deletions packages/extension/src/active-vta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,52 @@ export function parseAllVtaDids(raw: unknown): string[] {
}
}

/** The DIDComm mediator advertised by an onboarded agent, for a wallet that
* has no inbox of its own yet.
*
* Onboarding records each agent's advertised mediator on its `Connection`
* (`store.ts`) and, since the fix that removed the hardcoded default, writes
* it to the wallet's inbox setting too. Wallets onboarded BEFORE that fix
* have the connection but no setting — they were running on the hardcoded
* demo relay — and re-onboarding to acquire one is not a fair ask: it mints
* a fresh holder DID that every RP ACL must then be re-granted. So the
* backfill reads the answer already on disk. This mirrors `tspMediatorDid`,
* which the transport refresh backfills onto existing connections for the
* same reason.
*
* The active VTA's mediator wins; otherwise the first agent that advertises
* one, so a single-agent wallet backfills whether or not the active pointer
* has been set. Returns `undefined` when no agent advertises a mediator —
* a REST- or TSP-only deployment, where there is genuinely nothing to adopt. */
export async function readAgentMediatorDid(): Promise<string | undefined> {
const stored = await chrome.storage.local.get("pnm-connection/v3");
return parseAgentMediatorDid(stored["pnm-connection/v3"]);
}

export function parseAgentMediatorDid(raw: unknown): string | undefined {
if (typeof raw !== "string") return undefined;
try {
const parsed = JSON.parse(raw) as {
state?: {
connections?: {
activeVtaDid?: string | null;
vtas?: Record<string, { mediatorDid?: unknown }>;
};
};
};
const conns = parsed.state?.connections;
const vtas = conns?.vtas ?? {};
const active = conns?.activeVtaDid ? vtas[conns.activeVtaDid]?.mediatorDid : undefined;
if (typeof active === "string" && active) return active;
for (const entry of Object.values(vtas)) {
if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) return entry.mediatorDid;
}
return undefined;
} catch {
return undefined;
}
}

/** Read the active VTA's holder DID from the persisted connection
* store — without going through the holder loader. Returns `null`
* when no VTA is active.
Expand Down
27 changes: 26 additions & 1 deletion packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
parseAllVtaDids,
readActiveHolderDid,
readActiveVtaDid,
readAgentMediatorDid,
readAllVtaDids,
} from "./active-vta.js";
import { checkOriginPin, pinOrigin } from "./origin-pin.js";
Expand Down Expand Up @@ -94,6 +95,7 @@ import {
RUNTIME_LIST_CONTEXTS,
RUNTIME_LIST_DIDS,
RUNTIME_FORGET_HOLDER_RECORD,
RUNTIME_RESTART_INBOX,
RUNTIME_REFRESH_VTA_TRANSPORTS,
RUNTIME_UNLOCK_PRF,
RUNTIME_UNLOCK_APPROVER,
Expand Down Expand Up @@ -179,7 +181,7 @@ import {
type RuntimeWalletDefaultsResponse,
type VerifyRpDidResult,
} from "./bridge-protocol.js";
import { getSettings } from "./config.js";
import { getSettings, inboxToAdopt, setSettings } from "./config.js";
import { providerMatches, syncProviderRegistration } from "./content-registration.js";
import {
AGENT_NAME_UNREADABLE,
Expand Down Expand Up @@ -469,6 +471,20 @@ async function startInboundListener(): Promise<void> {
// otherwise a no-op.
const vtaDids = (await readAllVtaDids()).sort();
_lastInboundVtaDids = vtaDids;

// Backfill the inbox for a wallet onboarded before onboarding wrote one.
// Those wallets ran on a hardcoded demo relay that has since been removed,
// so without this they come up with no inbox and the only documented route
// back — re-onboarding — mints a new holder DID and invalidates every RP
// ACL. The agent's mediator is already on the persisted connection; adopting
// it applies the same rule onboarding now applies, at the one place that
// runs on every boot. `inboxToAdopt` declines when an inbox is already set,
// so this never moves an address in use.
const adopt = inboxToAdopt((await getSettings()).mediatorDid, await readAgentMediatorDid());
if (adopt) {
await setSettings({ mediatorDid: adopt });
console.info("[pnm inbound] inbox mediator backfilled from agent:", adopt);
}
// Seed _lastActiveVtaDid too — otherwise the first chrome.storage
// onChanged callback would see _lastActiveVtaDid=null and emit a
// spurious connectionchanged.
Expand Down Expand Up @@ -2421,6 +2437,15 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true; // async sendResponse
}

if ((message as { type?: string })?.type === RUNTIME_RESTART_INBOX) {
startInboundListener()
.then(() => sendResponse({ ok: true }))
.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_WALLET_LOCK_STATE) {
handleWalletLockState(message as RuntimeWalletLockStateRequest)
.then(sendResponse)
Expand Down
23 changes: 23 additions & 0 deletions packages/extension/src/bridge-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,14 @@ export interface RuntimeOnboardConnectRequest {
* `mediatorDid`". Matched on directly, never by parsing the message (R3.7). */
export const MEDIATOR_REQUIRED = "wallet/mediator-required";

/** Stable code meaning "this wallet has no inbox mediator configured, so
* nothing can be pushed to it". Distinct from `MEDIATOR_REQUIRED`, which is
* about the mediator an *onboarding* needs to route through: this one is
* about the wallet's own inbox, and it is reachable only through a wallet
* that was never onboarded or whose setting was cleared by hand. Matched on
* directly, never by parsing the message (R3.7). */
export const INBOX_NOT_CONFIGURED = "wallet/inbox-not-configured";

/** offscreen → any listener: onboarding reached a new phase.
*
* Fire-and-forget, emitted while `OFFSCREEN_ONBOARD_CONNECT` is still
Expand Down Expand Up @@ -762,6 +770,21 @@ export type RuntimeForgetHolderRecordResponse =
| { ok: true }
| { ok: false; error: string };

/** popup/options → background: re-open the inbound mediator sessions.
*
* Sent after the inbox mediator is changed by hand. The background's inbound
* reconcile otherwise runs only on boot and on a connection-store change, so
* a wallet whose operator moved its inbox would go on listening at the old
* relay — or, from an unset inbox, at nothing — until the next browser
* restart, with the settings page reporting success over it. */
export const RUNTIME_RESTART_INBOX = "vta-wallet/restart-inbox" as const;

export interface RuntimeRestartInboxRequest {
type: typeof RUNTIME_RESTART_INBOX;
}

export type RuntimeRestartInboxResponse = { ok: true } | { ok: false; error: string };

export const RUNTIME_REFRESH_VTA_TRANSPORTS = "vta-wallet/refresh-vta-transports" as const;

export interface RuntimeRefreshVtaTransportsRequest {
Expand Down
67 changes: 54 additions & 13 deletions packages/extension/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,36 @@
// while IndexedDB is available in every extension context (and is already the
// holder identity's backing store).
//
// The mediator DID is the load-bearing setting: it's baked into the holder's
// `did:peer:2` service endpoint at first mint, so changing it mints a NEW
// wallet DID (which must be re-granted in every RP's ACL). The options page
// is responsible for warning + forcing a re-mint when it changes; reading the
// config here never re-mints on its own.
// The mediator DID is the wallet's inbox: the relay an RP or executor pushes
// to when it needs to reach this wallet. It is written by onboarding from the
// agent's own advertised DIDComm mediator, and only overridden by hand by an
// operator running more than one relay.
//
// It used to be described here as "baked into the holder's `did:peer:2`
// service endpoint at first mint, so changing it mints a NEW wallet DID".
// That stopped being true at the M2C migration: a v4 holder is a `did:key`
// the VTA mints (`store/holder-identity.ts`), and the mediator is nowhere
// inside it. Changing the inbox now means re-registering the address with
// whoever routes to you — not a new identity.

import { IndexedDBKVStore } from "@openvtc/pnm-core";

/** The mediator the wallet uses for inbound + DIDComm login when unconfigured.
* The did-hosting demo mediator. A real deployment configures its own. */
export const DEFAULT_WALLET_MEDIATOR_DID =
"did:webvh:QmTS3a3H9Dk4ZMPAZ8jNWGeyPbuKrPbrPZcSbg8CJ6yynD:webvh.storm.ws:mediator";

export interface WalletSettings {
/** Mediator DID baked into the holder did:peer (inbox + DIDComm login). */
mediatorDid: string;
/**
* The wallet's inbox: the mediator an RP or executor pushes to in order to
* reach this wallet, and the relay the wallet authenticates to for DIDComm
* login.
*
* **Unset until onboarding writes it**, and unset is a real state, not a
* missing default. It previously fell back to a hardcoded demo mediator on
* a domain no deployment here runs, so every wallet that never touched the
* advanced routing field ran its inbox through a third party's host while
* Setup told the operator it had been "set up automatically from your
* agent". A default that is wrong everywhere but one workspace is worse
* than none: absent, the wallet can say the inbox is not configured; wrong,
* it can only appear to work. (R5 — config absence is the restrictive case.)
*/
mediatorDid?: string;
/** Optional default VTA DID prefilled into the step-up flow. */
defaultStepUpVtaDid?: string;
/** Optional default VTA mediator DID prefilled into the step-up flow. */
Expand Down Expand Up @@ -99,6 +113,33 @@ export interface WalletSettings {
preferTsp?: boolean;
}

/**
* Which inbox to persist after onboarding at an agent.
*
* Returns the mediator to write, or `undefined` to leave the setting alone.
* The rule, in one place because it is easy to state and easy to get subtly
* wrong at a call site:
*
* - Nothing advertised → write nothing. An agent that publishes no DIDComm
* mediator cannot push to a wallet; an inbox invented here would be a
* relay nobody was ever asked about. Unset is the honest state and the
* self-test reports it.
* - Already set → leave it. Either an operator chose it deliberately (they
* run more than one relay), or a previous onboarding adopted it — and the
* inbox is an *address* other parties already route to, so a second
* onboarding silently moving it would strand everyone who knows this
* wallet. Changing it stays a deliberate act with its own confirmation.
* - Otherwise → adopt the agent's.
*/
export function inboxToAdopt(
current: string | undefined,
advertised: string | undefined,
): string | undefined {
if (!advertised) return undefined;
if (current) return undefined;
return advertised;
}

const SETTINGS_KEY = "pnm/settings/v1";

/** Read the current settings, falling back to defaults for unset fields. */
Expand All @@ -112,7 +153,7 @@ export async function getSettings(): Promise<WalletSettings> {
const encryptHolderSecret =
typeof s?.encryptHolderSecret === "boolean" ? s.encryptHolderSecret : false;
return {
mediatorDid: s?.mediatorDid || DEFAULT_WALLET_MEDIATOR_DID,
...(s?.mediatorDid ? { mediatorDid: s.mediatorDid } : {}),
...(s?.defaultStepUpVtaDid ? { defaultStepUpVtaDid: s.defaultStepUpVtaDid } : {}),
...(s?.defaultStepUpVtaMediatorDid
? { defaultStepUpVtaMediatorDid: s.defaultStepUpVtaMediatorDid }
Expand Down
25 changes: 16 additions & 9 deletions packages/extension/src/holder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@ import {
import { getSettings } from "./config.js";
import { WebAuthnPrfSecretWrap } from "./webauthn-prf-wrap.js";

/** The wallet's inbox mediator DID — configurable (see `config.ts`), baked
* into the holder `did:peer:2` service endpoint at first mint so RPs can
* route inbound DIDComm (RP-initiated `confirm` requests) to the wallet. It's
* also the mediator the wallet authenticates to for DIDComm login, so the
* wallet is already a registered recipient there.
/** The wallet's inbox mediator DID — the relay RPs and executors push to for
* inbound DIDComm (RP-initiated `confirm` requests, `task-consent/request`),
* and the one the wallet authenticates to for DIDComm login, so it is already
* a registered recipient there.
*
* NOTE: this is baked into the DID at first mint, so changing it mints a NEW
* holder DID (which must be re-granted in the RP ACL). The options page
* handles that re-mint explicitly; `loadHolder` only uses it for a fresh mint. */
export async function getWalletMediatorDid(): Promise<string> {
* **`undefined` until onboarding writes it from the agent's advertised
* mediator**, and callers must treat that as "this wallet has no inbox"
* rather than substituting one. There was a hardcoded fallback here; it
* pointed at a demo host belonging to no deployment in use, and silently
* became the inbox of every wallet whose operator never opened the advanced
* routing field. See `config.ts`.
*
* The old note about this being baked into the holder `did:peer:2` at first
* mint no longer applies — a v4 holder is a VTA-minted `did:key` and carries
* no mediator. Changing the inbox re-registers an address; it does not mint
* an identity. */
export async function getWalletMediatorDid(): Promise<string | undefined> {
return (await getSettings()).mediatorDid;
}

Expand Down
Loading
Loading