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
95 changes: 91 additions & 4 deletions packages/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,10 @@ import {
// it constantly and a missed event would otherwise persist until the next one.
chrome.permissions.onAdded.addListener(() => void syncProviderRegistration());
chrome.permissions.onRemoved.addListener(() => void syncProviderRegistration());
chrome.runtime.onStartup.addListener(() => void syncProviderRegistration());
chrome.runtime.onStartup.addListener(() => {
void syncProviderRegistration();
void followAgentInbox();
});
void syncProviderRegistration().then((matches) => {
console.info(
matches.length > 0
Expand All @@ -216,6 +219,9 @@ void syncProviderRegistration().then((matches) => {
chrome.runtime.onInstalled.addListener((details) => {
console.info("[pnm] extension installed:", details.reason);
void ensurePushWake();
// An update is the other moment worth one DID-document read: it is when a
// deployment's pieces tend to move together.
void followAgentInbox();

// Fresh install: open setup in a tab rather than leaving the user to find
// it. The order of the steps there is load-bearing — the agent's address
Expand Down Expand Up @@ -480,10 +486,26 @@ async function startInboundListener(): Promise<void> {
// 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());
const settings = await getSettings();
const adopt = inboxToAdopt(
{ did: settings.mediatorDid, source: settings.mediatorDidSource },
await readAgentMediatorDid(),
);
if (adopt) {
await setSettings({ mediatorDid: adopt });
console.info("[pnm inbound] inbox mediator backfilled from agent:", adopt);
await setSettings({ mediatorDid: adopt, mediatorDidSource: "agent" });
console.info("[pnm inbound] inbox mediator adopted from agent:", adopt);
} else if (!settings.mediatorDidSource && vtaDids.length > 0) {
// Nothing adopted and nothing on record choosing what is there: the
// persisted connection carries no mediator to adopt. Said out loud,
// because the alternative is a wallet that silently keeps whatever relay
// it had and a boot that looks like it did its job. Refreshing transports
// (Setup → the agent's transports) re-resolves the DID document and fills
// the connection in.
console.warn(
"[pnm inbound] inbox relay is unattributed and no onboarded agent " +
"advertises one to adopt — refresh the agent's transports, or set a " +
"relay under Setup → Message routing.",
);
}
// Seed _lastActiveVtaDid too — otherwise the first chrome.storage
// onChanged callback would see _lastActiveVtaDid=null and emit a
Expand Down Expand Up @@ -1383,6 +1405,71 @@ async function handleWalletLockState(
})) as RuntimeWalletLockStateResponse;
}

/**
* Follow the agent if it has moved its relay.
*
* Distinct from the adopt-when-blank backfill in `startInboundListener`, and
* for a reason that only shows up when you ask how inbound actually arrives:
* **a v4 holder is a `did:key`, which carries no service endpoint, and the
* wallet publishes its inbox to nobody.** There is no discovery path. So an
* executor pushing to this wallet can only hand the message to a mediator it
* already knows — its own — and the wallet hears it only if it is listening
* there. The inbox is not an independent address the wallet owns; it is
* "wherever my agent's relay is", and a wallet pinned to yesterday's mediator
* goes dark while every check still reports green.
*
* So an `agent`-sourced inbox FOLLOWS the agent's DID document. An
* `operator`-sourced one never moves: someone running more than one relay
* chose it, and this is exactly the override that has to survive.
*
* Run on browser startup and on update, not per worker spin-up. MV3 respawns
* the worker on almost any event, and a DID-document fetch on each of those
* would be a lot of network for a value that changes when an operator
* redeploys a mediator. The blank/unattributed backfill still runs every
* spin-up — it reads the persisted connection and costs nothing.
*/
async function followAgentInbox(): Promise<void> {
const settings = await getSettings();
if (settings.mediatorDidSource === "operator") return; // pinned, deliberately

const vtaDid = await readActiveVtaDid();
if (!vtaDid) return;

let live: string | undefined;
try {
const resp = await handleRefreshVtaTransports({
type: RUNTIME_REFRESH_VTA_TRANSPORTS,
vtaDid,
});
if (!resp.ok) throw new Error(resp.error);
live = resp.result.mediatorDid;
} catch (e) {
// A DID document we could not read says nothing about where the relay is,
// so it must not be read as "it moved to nowhere". Keep what we have.
console.warn("[pnm inbound] could not re-resolve the agent's relay:", e);
return;
}

if (!live) {
console.warn(
"[pnm inbound] the agent advertises no DIDComm relay — nothing can be " +
"pushed to this wallet through it.",
);
return;
}
if (live === settings.mediatorDid) return;

await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
console.info(
"[pnm inbound] the agent moved its relay:",
settings.mediatorDid ?? "(none)",
"→",
live,
);
// Re-open on the relay that can actually reach us.
await startInboundListener();
}

async function handleRefreshVtaTransports(
req: RuntimeRefreshVtaTransportsRequest,
): Promise<RuntimeRefreshVtaTransportsResponse> {
Expand Down
71 changes: 60 additions & 11 deletions packages/extension/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@

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

/** Who put the inbox mediator there. Absent on records written before this
* existed — treated as "nobody is on record", which is the truth. */
export type InboxSource = "agent" | "operator";

export interface WalletSettings {
/**
* The wallet's inbox: the mediator an RP or executor pushes to in order to
Expand All @@ -35,6 +39,12 @@ export interface WalletSettings {
* it can only appear to work. (R5 — config absence is the restrictive case.)
*/
mediatorDid?: string;

/** Provenance for `mediatorDid`: `agent` when onboarding (or the boot
* backfill) adopted the agent's advertised relay, `operator` when a person
* typed it into Setup → Message routing. Read by `inboxToAdopt`; see the
* note there for why the value alone was not enough to go on. */
mediatorDidSource?: InboxSource;
/** 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 @@ -124,27 +134,62 @@ export interface WalletSettings {
* 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.
* - Set, and someone is on record choosing it → leave it here. An operator
* pin is final; an `agent`-sourced one is not frozen either, but moving it
* is the job of `followAgentInbox` in `background.ts`, which re-resolves
* the agent's DID document rather than guessing from a cached connection.
* This function only ever fills a blank.
* - Otherwise → adopt the agent's. That covers an unset inbox and, once, the
* records written before provenance existed.
*
* **Why `source` had to exist.** The first cut of this keyed on "is anything
* set?", which read as sufficient and was not. `setSettings` merged the
* *defaulted* view of the settings and wrote it back, so under the old
* hardcoded default any unrelated write — turning on the passkey lock,
* toggling TSP preference — persisted the demo mediator DID into IndexedDB as
* though it had been chosen. Wallets therefore carry a stored inbox nobody
* picked, indistinguishable by value from a deliberate one, and the migration
* that was supposed to rescue them declined to touch it. (That write-back is
* fixed in `setSettings` below; this handles the records it already made.)
*
* The cost is stated rather than hidden: an operator who hand-set a mediator
* before provenance existed has it adopted over, once. With nothing deployed
* and the alternative being wallets stuck on a relay in someone else's
* deployment, that is the right side to err on — and the person affected is
* exactly the person who knows how to set it again.
*/
export function inboxToAdopt(
current: string | undefined,
current: { did?: string | undefined; source?: InboxSource | undefined },
advertised: string | undefined,
): string | undefined {
if (!advertised) return undefined;
if (current) return undefined;
// A person chose this relay. Never overridden.
if (current.source === "operator") return undefined;
// Already adopted from an agent. Left alone even when the active agent
// changes: the inbox is an address others route to, and chasing the active
// VTA would move it out from under them.
if (current.did && current.source === "agent") return undefined;
// Either nothing is set, or something is set that no one recorded choosing —
// which is every record written before provenance existed. Adopt.
return advertised;
}

const SETTINGS_KEY = "pnm/settings/v1";

/** Read the current settings, falling back to defaults for unset fields. */
/** The record as it is actually stored — no defaults applied.
*
* Separate from `getSettings` because the two have genuinely different jobs,
* and conflating them is what produced the inbox defect: `setSettings` merged
* the *defaulted* view and wrote it back, so every read-modify-write turned
* derived defaults into persisted values that later code could no longer tell
* apart from choices. A write must merge onto what is on disk. */
async function storedSettings(): Promise<Partial<WalletSettings>> {
return (await new IndexedDBKVStore().get<Partial<WalletSettings>>(SETTINGS_KEY)) ?? {};
}

export async function getSettings(): Promise<WalletSettings> {
const s = await new IndexedDBKVStore().get<Partial<WalletSettings>>(SETTINGS_KEY);
const s = await storedSettings();
// `encryptHolderSecret` defaults to FALSE until the popup-driven
// WebAuthn-enrol path lands — see the field's docblock for the
// architectural constraint (offscreen + WebAuthn don't mix).
Expand All @@ -154,6 +199,9 @@ export async function getSettings(): Promise<WalletSettings> {
typeof s?.encryptHolderSecret === "boolean" ? s.encryptHolderSecret : false;
return {
...(s?.mediatorDid ? { mediatorDid: s.mediatorDid } : {}),
...(s?.mediatorDidSource === "agent" || s?.mediatorDidSource === "operator"
? { mediatorDidSource: s.mediatorDidSource }
: {}),
...(s?.defaultStepUpVtaDid ? { defaultStepUpVtaDid: s.defaultStepUpVtaDid } : {}),
...(s?.defaultStepUpVtaMediatorDid
? { defaultStepUpVtaMediatorDid: s.defaultStepUpVtaMediatorDid }
Expand All @@ -176,6 +224,7 @@ export async function getSettings(): Promise<WalletSettings> {

/** Merge a partial update into the stored settings. */
export async function setSettings(patch: Partial<WalletSettings>): Promise<void> {
const current = await getSettings();
await new IndexedDBKVStore().put(SETTINGS_KEY, { ...current, ...patch });
// Merged onto the STORED record, not the defaulted one. See `storedSettings`.
const stored = await storedSettings();
await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, ...patch });
}
8 changes: 6 additions & 2 deletions packages/extension/src/offscreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1829,9 +1829,13 @@ async function doOnboardConnect(params: OnboardConnectParams): Promise<OnboardCo
// advertises neither (REST- or TSP-only) leaves the inbox unset, which is
// the honest state: nothing can be pushed to this wallet, and `runDiagnostics`
// says so rather than pointing at a mediator that was never asked.
const inbox = inboxToAdopt((await getSettings()).mediatorDid, services.didcomm?.mediatorDid);
const onboardSettings = await getSettings();
const inbox = inboxToAdopt(
{ did: onboardSettings.mediatorDid, source: onboardSettings.mediatorDidSource },
services.didcomm?.mediatorDid,
);
if (inbox) {
await setSettings({ mediatorDid: inbox });
await setSettings({ mediatorDid: inbox, mediatorDidSource: "agent" });
console.info("[pnm onboard] inbox mediator set from agent:", inbox);
}

Expand Down
4 changes: 3 additions & 1 deletion packages/extension/src/setup-pane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,9 @@ export function SetupPane() {
setBusy(true);
setStatus(null);
try {
await setSettings({ mediatorDid: inbox.trim() });
// Stamped as the operator's choice, which the boot backfill never
// overrides — this is the one place a person picks a relay.
await setSettings({ mediatorDid: inbox.trim(), mediatorDidSource: "operator" });
setSavedInbox(inbox.trim());
setRoutingOpen(false);
setHolderDid((await readActiveHolderDid()) ?? "");
Expand Down
30 changes: 24 additions & 6 deletions packages/extension/tests/wallet-inbox.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -19,23 +19,41 @@ const AGENT = "did:webvh:QmAgentMediator:agent.example:mediator";
const OTHER = "did:webvh:QmOtherMediator:other.example:mediator";

test("adopts the agent's relay when the wallet has none", () => {
assert.equal(inboxToAdopt(undefined, AGENT), AGENT);
assert.equal(inboxToAdopt({}, AGENT), AGENT);
});

test("leaves an inbox the operator already chose", () => {
test("leaves an inbox the operator chose", () => {
// The operator running two relays picked this one on purpose.
assert.equal(inboxToAdopt(OTHER, AGENT), undefined);
assert.equal(inboxToAdopt({ did: OTHER, source: "operator" }, AGENT), undefined);
});

test("a second onboarding does not move an address others already route to", () => {
assert.equal(inboxToAdopt(AGENT, OTHER), undefined);
assert.equal(inboxToAdopt({ did: AGENT, source: "agent" }, OTHER), undefined);
});

test("an agent advertising no mediator leaves the inbox unset, not invented", () => {
// Unset is reported by the self-test as "nothing can reach this wallet".
// Substituting anything here is what caused the original defect.
assert.equal(inboxToAdopt(undefined, undefined), undefined);
assert.equal(inboxToAdopt("", undefined), undefined);
assert.equal(inboxToAdopt({}, undefined), undefined);
assert.equal(inboxToAdopt({ did: "" }, undefined), undefined);
});

test("adopts over a stored inbox nobody is on record choosing", () => {
// The case that defeated the first migration. `setSettings` merged the
// DEFAULTED settings and wrote them back, so any unrelated write — the
// passkey lock, the TSP toggle — persisted the old hardcoded demo mediator
// as though it had been picked. By value it is indistinguishable from a
// deliberate choice; by provenance it is not.
const DEMO = "did:webvh:QmDemoRelay:demo.example:mediator";
assert.equal(inboxToAdopt({ did: DEMO }, AGENT), AGENT);
});

test("the blank-filling adoption happens once, not on every boot", () => {
// Stamped `agent` on the way in, so the per-spin-up backfill leaves it
// alone. Moving an agent-sourced inbox when the agent moves its relay is
// `followAgentInbox`'s job — it re-resolves the DID document, where this
// function only ever reads a cached connection.
assert.equal(inboxToAdopt({ did: AGENT, source: "agent" }, OTHER), undefined);
});

// ─── No mediator may be baked into the source again ───
Expand Down
Loading