Skip to content

fix(inbox): adopt an unattributed relay, and follow the agent when it moves - #149

Merged
stormer78 merged 2 commits into
mainfrom
fix/inbox-adopt-unattributed
Aug 31, 2026
Merged

fix(inbox): adopt an unattributed relay, and follow the agent when it moves#149
stormer78 merged 2 commits into
mainfrom
fix/inbox-adopt-unattributed

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Follow-up to #148, which did not fire. Reported from a self-test still showing inbox mediator → https://mediator.vtc.storm.ws on a wallet whose agent is dids.eu.openvtc.net, after a clean pull, build and extension restart.

Why the migration didn't fire

Not the rule — the data. It was in the same file:

export async function setSettings(patch) {
  const current = await getSettings();          // mediatorDid: DEFAULT_WALLET_MEDIATOR_DID
  await put(SETTINGS_KEY, { ...current, ...patch });
}

setSettings merged the defaulted settings and wrote them back. Under the old hardcoded default, any unrelated write — turning on the passkey lock (encrypt-holder.ts), toggling TSP preference, saving a step-up default — persisted the demo mediator DID into IndexedDB as though a person had chosen it.

So affected wallets don't have an unset inbox. They have a stored one, byte-identical to a deliberate choice, and inboxToAdopt declining to move "an inbox that is already set" declined to move precisely the wallets the migration existed for. The rule was right; keying it on the value alone was never enough.

The fix

mediatorDidSource records who put the relay there:

source meaning backfill
operator a person typed it into Setup → Message routing never overridden
agent onboarding or the boot backfill adopted it left alone — the inbox is an address others route to, so it must not chase the active VTA
absent nobody is on record — every record written before now adopted once

Self-limiting without a version counter: after one boot every record has a source.

The cost, stated rather than buried: an operator who hand-set a mediator before provenance existed has it adopted over, once. With nothing deployed, the alternative is wallets stuck on a relay in someone else's deployment — and the person affected is exactly the one who knows how to set it again.

Root cause fixed too

setSettings now merges onto the stored record via a storedSettings() helper. The write-back was not specific to the mediator: every derived default (encryptHolderSecret: false, preferTsp: true) became a persisted value that later code could no longer tell apart from a choice. That is the shape of the bug, not one instance of it.

Also

A boot that adopts nothing because no onboarded agent advertises a relay now warns, instead of looking like it did its job. That is the remaining way this can silently no-op: the backfill reads the mediator off the persisted connection, so a connection recorded without one has nothing to adopt. Refreshing the agent's transports re-resolves the DID document and fills it in.

Verification

Lint, build and 685 tests pass; two new cases pin the two that matter — adopting over an unattributed stored relay, and not re-adopting on the next boot.

Not verified by me: the live path. After this one, the self-test's inbox row should name mediator.eu.openvtc.net. If it still doesn't, the service-worker console will now say which of the two reasons it is.

Pre-merge checklist

- [x] No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2) — no network code
- [x] No lock held across a network await (R1.3) — n/a
- [x] No local state committed before its remote effect, or the flow is resumable with an idempotency key (R2.1) — settings write is local and idempotent
- [x] Every retry is bounded + backed off; non-idempotent ops are not blind-retried (R1.4) — unchanged
- [x] Accept/poll/listen loops survive transient errors (R1.5) — unchanged
- [x] Acks/deletes happen only after durable handoff (R1.6) — inbound path untouched
- [x] New/changed wire types: camelCase, deny_unknown_fields where security-relevant, schema registered, all consumers (incl. JS) updated (R3.*) — no wire types; one new local settings field, validated against its union on read
- [x] Config absence = most restrictive; fail-closed if enforcement can't start (R5.*) — unchanged from #148; absent provenance is treated as unattributed, not as authority
- [x] Logs/status claim only what was verified; background-job failures are surfaced (R6.*) — this is the change: a no-op backfill now says so
- [x] "Process dies on the next line" answered for every mutation touched (R2.1) — single idempotent write; a death before it re-runs the backfill next boot
- [x] Deviations from this guide flagged explicitly with rule numbers — none

#148 removed the hardcoded demo mediator and backfilled the agent's relay
for wallets that had none. It did not fire, and the reason was in the
same file: `setSettings` merged the *defaulted* settings and wrote them
back.

  const current = await getSettings();   // mediatorDid: DEFAULT_…
  await put(SETTINGS_KEY, { ...current, ...patch });

Under the old default that meant any unrelated write — turning on the
passkey lock, toggling TSP preference, saving a step-up default —
persisted the demo mediator DID into IndexedDB as though it had been
chosen. So wallets carry a stored inbox nobody picked, and `inboxToAdopt`
declining to move "an inbox that is already set" declined to move exactly
the ones the migration existed for. The rule was right; the value alone
was never enough to go on.

`mediatorDidSource` records who put the relay there. `operator` is a
person typing into Setup → Message routing and is never overridden.
`agent` is onboarding or the boot backfill, and is left alone too — the
inbox is an address others route to, so it must not chase the active VTA.
Absent means nobody is on record, which is the truth for every record
written before now, and those adopt the agent's relay once. Self-limiting
without a version counter: after one boot every record has a source.

The cost, stated rather than buried: an operator who hand-set a mediator
before provenance existed has it adopted over, once. With nothing
deployed, the alternative is wallets stuck on a relay in someone else's
deployment, and the person affected is the one who knows how to set it
again.

`setSettings` now merges onto the STORED record. That write-back is the
root cause and it was not specific to the mediator — every derived
default became a persisted value that later code could no longer tell
apart from a choice.

Also: a boot that adopts nothing because no onboarded agent advertises a
relay now says so, instead of looking like it did its job.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The inbox is not an address this wallet owns. A v4 holder is a `did:key`,
which carries no service endpoint, and the wallet publishes its mediator
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. "Wherever my
agent's relay is" is what the inbox means.

Which makes the previous commit's rule half right. Pinning an
agent-sourced inbox forever is a wallet that goes dark the day its
operator redeploys the mediator, with every check still green — the same
failure this thread started with, one deployment later.

`followAgentInbox` re-resolves the agent's DID document and moves an
`agent`-sourced inbox when the advertised relay has changed, then
re-opens the sessions on it. An `operator`-sourced inbox never moves;
that override is the whole reason provenance exists.

On `onStartup` and `onInstalled`, not per worker spin-up: MV3 respawns
the worker on almost any event and a DID-document fetch on each would be
a lot of network for a value that changes when someone redeploys. The
blank-filling backfill still runs every spin-up — it reads the persisted
connection and costs nothing.

A document that cannot be read is not evidence the relay moved, so a
failed resolve keeps what we have and says so.

Known limitation, unchanged by this and worth its own issue: one inbox
setting against a multi-VTA wallet. Two agents on different mediators
cannot both reach it.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78 stormer78 changed the title fix(inbox): adopt over a relay nobody is on record choosing fix(inbox): adopt an unattributed relay, and follow the agent when it moves Aug 31, 2026
@stormer78

Copy link
Copy Markdown
Contributor Author

Pushed a second commit after a question from review: mediators change over time in the DID document — should the wallet re-resolve on startup?

Checked, and the answer is stronger than freshness. A v4 holder is a did:key, which carries no service endpoint, and the wallet publishes its inbox mediator to nobody — device/set-wake's suggestedTriggers is advisory and never carries it. There is no discovery path at all. 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 happens to be listening there.

That means the inbox is not an independent address this wallet owns. It means "wherever my agent's relay is" — and the first commit's rule of pinning an agent-sourced inbox forever is a wallet that goes dark the day its operator redeploys the mediator, with every check still green. The same failure this thread opened with, one deployment later.

followAgentInbox re-resolves the agent's DID document and moves an agent-sourced inbox when the advertised relay changed, then re-opens the sessions on it. operator-sourced never moves — that override is the whole reason provenance exists, and it now earns its keep in both directions.

On onStartup and onInstalled rather than per worker spin-up: MV3 respawns the worker on nearly any event, and a DID-document fetch on each would be a lot of network for a value that changes when someone redeploys a mediator. The blank-filling backfill still runs every spin-up — it reads the persisted connection and costs nothing. Happy to make it more eager if that trade looks wrong.

A document that cannot be read is not evidence the relay moved, so a failed resolve keeps what we have and says so rather than treating unreadable as absent.

Separate limitation this surfaced, not fixed here and probably worth its own issue: one inbox setting against a multi-VTA wallet. Two agents on different mediators cannot both reach it — whichever the inbox names wins and the other agent's pushes never arrive.

@stormer78
stormer78 merged commit 4e4bc2e into main Aug 31, 2026
3 checks passed
@stormer78
stormer78 deleted the fix/inbox-adopt-unattributed branch August 31, 2026 06:29
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Aug 31, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

3 AI-confirmed issues, 3 findings need a human to review/validate.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #149

Field Value
Repository OpenVTC/vta-browser-plugin
Branch fix/inbox-adopt-unattributedmain
Validated 2026-09-05
Scan ID f766f8d2
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 1 · with findings: 1 · files: 5 · findings: 7

Module Files scanned Findings
packages/extension 5 7

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 3 3

⚠️ 3 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.


🔒 Security Issues

Confirmed Vulnerabilities (3)

🟡 inboxToAdopt() silently overwrites legacy unattributed operator-chosen mediator without confirmation

Field Detail
Severity MEDIUM
Location packages/extension/src/config.ts:168
Finding ID github_pr-5092c1acbee9
CWE CWE-345, CWE-1188
OWASP A08:2021 - Software and Data Integrity Failures
MITRE ATT&CK T1565 - Data Manipulation
CAPEC CAPEC-176
DREAD 4.6
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • The code path is confirmed reachable (EP-001/EP-005, no auth barrier, internal exposure) which supports maintaining rather than downgrading severity. However, exploitation requires a compound precondition: (1) a legacy wallet with mediatorDid set but source unattributed, AND (2) attacker ability to control/spoof the advertised mediator DID via the VTA agent. No CVSS applies (first-party logic flaw), exploit maturity is conceptual with no public tooling, and blast radius is bounded to a subset of legacy installations. This matches medium calibration: limited/theoretical impact scope, no confirmed exploit in the wild, specific conditions required.
  • Composite score: 5.5
  • Environment: production

Summary: config.ts's inboxToAdopt() function treats any stored mediatorDid lacking the mediatorDidSource provenance tag as unclaimed and unconditionally adopts the agent-advertised value over it. This is an acknowledged one-time migration cost for legacy records, but it also means an attacker who controls the advertised mediator DID can force a takeover of any wallet still in the legacy state, with no operator confirmation and only a console.info log.

📝 Description:

A legacy wallet with a deliberately operator-configured mediator (before provenance tracking existed) can have that choice silently discarded and replaced by whatever the active agent currently advertises, without any confirmation dialog — potentially routing DIDComm traffic to an unintended or attacker-controlled relay.

🧪 Proof of Concept:

The final fallthrough return advertised; applies identically whether current.did is undefined (genuinely blank) or set-but-unattributed (a legacy deliberate choice) — the function cannot and does not distinguish these two states, by the authors' own admission in the surrounding documentation.

export function inboxToAdopt(
  current: { did?: string | undefined; source?: InboxSource | undefined },
  advertised: string | undefined,
): string | undefined {
  if (!advertised) return undefined;
  // A person chose this relay. Never overridden.
  if (current.source === "operator") return undefined;
  // Already adopted from an agent. Left alone...
  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;
}

Vulnerable lines: 168, 178

🔎 Evidence: packages/extension/src/config.ts:168

export function inboxToAdopt(
  current: { did?: string; source?: InboxSource },
  advertised: string | undefined,
): string | undefined {
  if (!advertised) return undefined;
  if (current.source === "operator") return undefined;
  if (current.did && current.source === "agent") return undefined;
  return advertised;
}

💥 Impact:

A legacy wallet with a deliberately operator-configured mediator (before provenance tracking existed) can have that choice silently discarded and replaced by whatever the active agent currently advertises, without any confirmation dialog — potentially routing DIDComm traffic to an unintended or attacker-controlled relay.

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: none
  • Attack path: EP-001 (onStartup) / EP-005 (doOnboardConnect) → getSettings() reads legacy record with mediatorDid set, mediatorDidSource undefined → inboxToAdopt() falls through both guards → setSettings() overwrites at background.ts/offscreen.ts

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability low
Business impact medium
Public exploit None known
Environment unknown

Attack scenario: Legacy wallets with unattributed mediatorDid records are silently migrated to an agent-advertised value on next boot/onboarding, discarding an operator's original deliberate choice without confirmation.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Change the default behavior for unattributed-but-set records to preserve the existing value rather than silently adopting the advertised one, and surface a UI prompt so the operator makes an informed, auditable decision about migrating.

Vulnerable code:

if (current.did && current.source === "agent") return undefined;
return advertised;

Secure code:

// Treat any existing `current.did` without provenance as a choice to be
// preserved by default; require explicit operator action to migrate it.
if (current.did && !current.source) {
  console.warn("[pnm] legacy mediator lacks provenance — leaving as-is; prompt operator to confirm migration");
  return undefined;
}
return advertised;

Additional recommendations:

  • Add a one-time UI banner in setup-pane.tsx flagging legacy unattributed mediator records for operator review.
  • Persist a migration audit trail capturing old/new values and timestamps.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: config.ts lines 168-178 contain the exact function: 'export function inboxToAdopt(current, advertised) { if (!advertised) return undefined; if (current.source === "operator") return undefined; if (current.did && current.source === "agent") return undefined; return advertised; }'. The docblock explicitly states: 'The cost is stated rather than hidden: an operator who hand-set a mediator before provenance existed has it adopted over, once.' This confirms the exact behavior describe
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Mediator DID and provenance persisted in unencrypted, integrity-unprotected IndexedDB store

Field Detail
Severity MEDIUM
Location packages/extension/src/config.ts:80
Finding ID github_pr-1426ae06fc4c
CWE CWE-311, CWE-732
OWASP A02:2021 - Cryptographic Failures
MITRE ATT&CK T1005 - Data from Local System
CAPEC CAPEC-150, CAPEC-37
Reachability 🔴 Reachable
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • CWE-311/CWE-353-class issue (missing encryption/integrity protection) confirmed by direct code evidence at config.ts:80-91. Attack requires local storage access (malware/malicious extension) — no_network_exposure, no auth barrier bypass needed once local access is obtained, but that precondition itself is a meaningful barrier. No CVSS/EPSS exists since this is first-party code. Impact is integrity/confidentiality of mediator routing metadata, not full account/key compromise (no evidence keys are stored here). This aligns with medium: real but conditioned on local compromise, not internet-facing.
  • Composite score: 5.5
  • Environment: production

🔎 Evidence: packages/extension/src/config.ts:80

const SETTINGS_KEY = "pnm/settings/v1";
async function storedSettings(): Promise<Partial<WalletSettings>> {
  return (await new IndexedDBKVStore().get<Partial<WalletSettings>>(SETTINGS_KEY)) ?? {};
}

🧭 Reachability:

  • Network exposure: none
  • Auth barrier: none
  • Attack path: Local malware / co-installed malicious extension with storage access → direct IndexedDB read/write on 'pnm/settings/v1' → mediatorDid/mediatorDidSource tampering

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: config.ts shows 'const SETTINGS_KEY = "pnm/settings/v1"; async function storedSettings(): Promise<Partial> { return (await new IndexedDBKVStore().get<Partial>(SETTINGS_KEY)) ?? {}; }' and setSettings uses 'new IndexedDBKVStore().put(SETTINGS_KEY, {...stored, ...patch})' with no encryption call wrapping mediatorDid/mediatorDidSource fields — unlike encryptHolderSecret which has its own dedicated wrap logic (WebAuthnPrfSecretWrap) documented elsewher
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Race condition (TOCTOU) between reading and writing wallet settings allows lost updates

Field Detail
Severity MEDIUM
Location packages/extension/src/config.ts
Finding ID github_pr-c72878c52f6a
CWE CWE-367
OWASP A04:2021-Insecure Design
Detection Source threat_model

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • CWE-367 read-modify-write race with no CVSS score, no CVE, no exploit maturity, and no remote/attacker-controlled trigger — it is triggered by internal timing between two legitimate code paths (setup-pane.tsx and followAgentInbox()) in a browser extension. Impact is limited to integrity of local wallet settings (mediatorDid provenance), not confidentiality or availability of sensitive data, and there is no authentication bypass or RCE. This aligns with the medium band: real code-level flaw, reachable in the sense that both code paths execute in normal operation, but low severity due to lack of attacker control and narrow blast radius (single local extension instance).
  • Composite score: 4.8
  • Environment: production

📝 Description:

setSettings performs a non-atomic read-modify-write against IndexedDB: it reads the current stored settings, then writes back a merged object. Multiple concurrent callers (e.g. followAgentInbox triggered on startup/update, plus a concurrent user-initiated setSettings from setup-pane.tsx, plus the onboarding flow in offscreen.ts) can interleave their read and write phases.

🌱 Root Cause: No locking, versioning, or atomic compare-and-swap is used around the read-then-write sequence in setSettings/storedSettings.

🔎 Evidence: packages/extension/src/config.ts

export async function setSettings(patch: Partial<WalletSettings>): Promise<void> {
  // Merged onto the STORED record, not the defaulted one. See `storedSettings`.
  const stored = await storedSettings();
  await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, ...patch });
}

🎯 Attack Scenario:

An operator submits a manual mediatorDid via setup-pane.tsx at nearly the same time the extension's onStartup/onInstalled handlers invoke followAgentInbox(), which also calls setSettings. If the agent-sourced write's read happens before the operator's write commits, and the agent-sourced write commits after, the operator's deliberate 'operator' provenance pin can be silently overwritten/lost, subverting the never-override guarantee documented in inboxToAdopt/followAgentInbox.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 55%
  • AI Validation Evidence: EVIDENCE FOUND: config.ts explicitly shows the non-atomic pattern: 'export async function setSettings(patch: Partial): Promise { const stored = await storedSettings(); await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, ...patch }); }' — a separate get() then put() with no transaction spanning both, confirming TOCTOU potential. The code comment even acknowledges this pattern's history: 'Merged onto the STORED record, not the defaulted one.' EVIDENCE NOT FOUND: No lo
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

⚠️ Must-Review-By-Human (3)

Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.

🟡 Mediator DID adopted from unauthenticated DID document without integrity verification (relay hijack)

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts:1425
Finding ID github_pr-9035463a5ea7
CWE CWE-345, CWE-346, CWE-494
OWASP A08:2021 - Software and Data Integrity Failures
MITRE ATT&CK T1557 - Adversary-in-the-Middle, T1584 - Compromise Infrastructure
CAPEC CAPEC-151, CAPEC-668
DREAD 5.6
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — CWE-345/807 trust issue with code-evidence-confirmed reachability (background.ts:462-463) but exploitation requires a compound precondition — prior local compromise granting IndexedDB write access — which is not attacker-controlled remotely and has no network exposure. Impact is bounded to badge/UI spoofing as a social-engineering pretext, not direct authorization bypass of the DIDComm approval flow itself. This matches medium calibration: CVSS N/A but effectively CVSS 4-6.9 equivalent impact, no confirmed real-world exploit, specific conditions (local compromise) required for exploitation.
  • Composite score: 4.8
  • Environment: unknown

Summary: background.ts's followAgentInbox() re-resolves the active VTA's DID document on every browser startup/update and, if the extracted mediatorDid differs from the stored value, silently persists it as the wallet's trusted inbox with no signature or authenticity verification. An attacker who can influence DID document resolution (compromised resolver, DNS hijack, malicious webvh host) can redirect all inbound DIDComm traffic to a mediator they control.

📝 Description:

An attacker who can influence DID document resolution can silently redirect a victim wallet's entire inbound DIDComm channel to an attacker-controlled mediator, enabling interception, tampering, or dropping of credential offers, verifiable presentation requests, and step-up authentication messages intended for that wallet — undermining the wallet's core trust model.

🧪 Proof of Concept:

The function trusts whatever handleRefreshVtaTransports returns as live with no signature, DID-method-specific proof, or pinning check against a previously known-good value before persisting it and rebinding the inbound listener.

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) {
    console.warn("[pnm inbound] could not re-resolve the agent's relay:", e);
    return;
  }
  if (!live) { ... return; }
  if (live === settings.mediatorDid) return;

  await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
  ...
  await startInboundListener();
}

Vulnerable lines: 1425, 1466

🔎 Evidence: packages/extension/src/background.ts:1425

async function followAgentInbox(): Promise<void> {
  const settings = await getSettings();
  if (settings.mediatorDidSource === "operator") return;
  ...
  const resp = await handleRefreshVtaTransports({ type: RUNTIME_REFRESH_VTA_TRANSPORTS, vtaDid });
  live = resp.result.mediatorDid;
  ...
  await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });

💥 Impact:

An attacker who can influence DID document resolution can silently redirect a victim wallet's entire inbound DIDComm channel to an attacker-controlled mediator, enabling interception, tampering, or dropping of credential offers, verifiable presentation requests, and step-up authentication messages intended for that wallet — undermining the wallet's core trust model.

🧭 Reachability:

  • Network exposure: public
  • Auth barrier: none
  • Attack path: EP-001/EP-002 (chrome.runtime.onStartup/onInstalled) → followAgentInbox() → handleRefreshVtaTransports() → DID resolution → setSettings() write at background.ts:~1461 → startInboundListener()

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability medium
Business impact high
Public exploit None known
Environment unknown

Attack scenario: An attacker who controls/spoofs the VTA's DID document resolution can cause followAgentInbox() to silently adopt an attacker-controlled mediator on browser startup, hijacking the wallet's inbound DIDComm channel.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Add a cryptographic verification step against the resolved DID document (method-specific: signature chain for webvh, self-certifying key material for did:key derivatives) before trusting any extracted service endpoint. Consider surfacing a non-dismissible UI notification for agent-sourced relay migrations rather than only console logging.

Vulnerable code:

await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
...
await startInboundListener();

Secure code:

// Verify the DID document's authenticity/integrity per its DID method
// (e.g. verify webvh log signatures, or DID:key self-certification chain)
// BEFORE trusting the extracted service endpoint.
const verified = await verifyDidDocumentIntegrity(vtaDid, resp.result.didDocument);
if (!verified) {
  console.error("[pnm inbound] DID document failed integrity verification; refusing to adopt relay");
  return;
}
// Optionally require explicit operator confirmation on first migration.
await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
await startInboundListener();

Additional recommendations:

  • Pin DID resolution to TLS-verified/DNSSEC-backed transports.
  • Add a persistent, tamper-evident audit log for mediator migrations (see VULN-005).
  • Require operator confirmation on first automatic migration per session.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: config.ts (provided in full) shows inboxToAdopt() and getSettings()/setSettings() but background.ts's followAgentInbox() and handleRefreshVtaTransports() implementations are NOT in the provided source_files — only referenced by line numbers/snippets in the finding evidence. The config.ts docblock corroborates the design: 'Moving it when the agent moves its relay is followAgentInbox's job in background.ts, which re-resolves the agent's DID document rather than guessing from a cach
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.

🟡 Internal runtime message handler lacks sender-origin validation and rate limiting (potential DoS / forced re-resolution)

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts:1470
Finding ID github_pr-354e1dfb938f
CWE CWE-306, CWE-400
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1499 - Endpoint Denial of Service
CAPEC CAPEC-133, CAPEC-125
DREAD 4.6
Reachability 🔴 Reachable
Exploit Maturity theoretical
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • CWE-306/CWE-400 code-level finding with confirmed reachability (scanner: is_reachable=true, attack path EP-001) but no CVSS score, no known exploit in the wild, and explicitly low business impact (local DoS only, no credential/PII exposure). Exploitability is medium (requires chrome.runtime access, not fully unauthenticated remote). This does not meet HIGH criteria (no CVSS≥7 equivalent severity, no exploit maturity beyond conceptual, low business impact) — medium severity is accurate and unchanged.
  • Composite score: 4.7
  • Environment: unknown

Summary: handleRefreshVtaTransports() is reachable via an internal chrome.runtime message with no confirmed sender validation in the provided code, and no rate limiting. Repeated invocation by any code with runtime-messaging access to the extension could exhaust resources via repeated DID-document fetches, or be chained with VULN-001 to force repeated mediator churn.

📝 Description:

Uncontrolled invocation could cause excessive network calls (DID resolution) leading to service worker resource exhaustion, and — combined with VULN-001 — could be abused to force repeated mediator relay churn, destabilizing the wallet's inbound message delivery.

🧪 Proof of Concept:

No sender validation is visible around this handler in the reduced source, and the recon agent's EP-004 explicitly notes auth_required: false for this entry point.

async function handleRefreshVtaTransports(
  req: RuntimeRefreshVtaTransportsRequest,
): Promise<RuntimeRefreshVtaTransportsResponse> {
  // body not shown in reduced excerpt — assumed to process req.vtaDid directly
}

Vulnerable lines: 1470, 1472

🔎 Evidence: packages/extension/src/background.ts:1470

async function handleRefreshVtaTransports(
  req: RuntimeRefreshVtaTransportsRequest,
): Promise<RuntimeRefreshVtaTransportsResponse> {

💥 Impact:

Uncontrolled invocation could cause excessive network calls (DID resolution) leading to service worker resource exhaustion, and — combined with VULN-001 — could be abused to force repeated mediator relay churn, destabilizing the wallet's inbound message delivery.

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: none
  • Attack path: chrome.runtime.sendMessage (EP-004) → onMessage listener (not shown in excerpt, assumed to route to handleRefreshVtaTransports) → DID resolution → potential setSettings() write via followAgentInbox()

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability low
Business impact low
Public exploit None known
Environment unknown

Attack scenario: A sender with runtime-messaging access to the extension can repeatedly invoke handleRefreshVtaTransports with no visible authentication or rate limiting, risking resource exhaustion or forced mediator re-resolution.

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Validate that MessageSender.id matches the extension's own ID before processing, and apply a debounce/rate-limit per sender to prevent resource-exhaustion abuse.

Vulnerable code:

async function handleRefreshVtaTransports(
  req: RuntimeRefreshVtaTransportsRequest,
): Promise<RuntimeRefreshVtaTransportsResponse> {

Secure code:

chrome.runtime.onMessage.addListener((req, sender, sendResponse) => {
  if (sender.id !== chrome.runtime.id) {
    sendResponse({ ok: false, error: "unauthorized sender" });
    return;
  }
  if (isRateLimited(sender)) {
    sendResponse({ ok: false, error: "rate limited" });
    return;
  }
  handleRefreshVtaTransports(req).then(sendResponse);
  return true;
});

Additional recommendations:

  • Audit manifest.json for externally_connectable and host_permissions scope to confirm actual exposure.
  • Add structured logging of message sender identity for anomaly detection.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: Only the function signature snippet is provided: 'async function handleRefreshVtaTransports(req: RuntimeRefreshVtaTransportsRequest): Promise {' — the body, and any chrome.runtime.onMessage listener wiring with sender validation, are not in source_files. EVIDENCE NOT FOUND: No onMessage listener code, no sender.id checks, no rate-limiting code was found in any provided file. background.ts's full content was not supplied. CHANGED VS PRE-EXISTIN
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.

🔵 Unsafe Formatstring (3 occurrences)

Field Detail
Severity LOW
Location packages/extension/src/offscreen.ts:635
Finding ID github_pr-8e5283452ed4
OWASP A01:2021 - Broken Access Control
CVSS 4.0 3.5
Exploit Maturity conceptual
Detection Source mcp_semgrep

Summary: Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co — 3 occurrence(s): offscreen.ts:635, offscreen.ts:1258, offscreen.ts:1267

📝 Description:

Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co

🌱 Root Cause: Unsafe Formatstring

🔧 Remediation:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

Priority: Short-term

Unsafe Formatstring: Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 85%
  • AI Validation Evidence: EVIDENCE FOUND: The evidence code_snippet field is empty (''), and offscreen.ts's actual content at line 635 was not included in source_files at all — only unrelated offscreen.ts references appear in comments within webauthn-prf-unlock.ts. EVIDENCE NOT FOUND: No console.log/util.format call, no string concatenation pattern, and no surrounding function context could be located anywhere in the provided source files for offscreen.ts. CHANGED VS PRE-EXISTING: Cannot determine whether offscreen.ts li
  • Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.


Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.

Complementary: 🛡️ **Threat Model & Affect Analysis**
Details

🛡️ Threat Model & Affect Analysis — PR #149

Field Value
Repository OpenVTC/vta-browser-plugin
Branch fix/inbox-adopt-unattributedmain
Generated 2026-09-05

ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.


📋 Affect Analysis

Change Summary

This PR introduces a provenance model ('agent' vs 'operator') for the wallet's DIDComm inbox mediator DID, fixes a real defect where setSettings() merged the defaulted settings view and silently persisted a hardcoded demo mediator as if chosen, and adds followAgentInbox() to re-resolve and follow the agent's advertised relay on browser startup/update so agent-sourced (non-pinned) wallets do not go dark when their agent redeploys a mediator.

Diff: +194 / -27 lines
Types: security, bugfix, feature, test

🧩 Affected Components

Component Impact Change What Changed
Wallet Settings / Config Store (mediatorDid trust model) critical modified Introduced provenance-tagged trust decisions for the inbox mediator DID and fixed a read-modify-write defect that silently persisted default
Background Service Worker (automatic relay-following) critical new New followAgentInbox() function runs automatically on browser startup and extension install/update, re-resolving the active VTA's DID docume
Offscreen Onboarding Flow medium modified doOnboardConnect now tags automatically-adopted mediator DIDs with 'agent' provenance during onboarding.
Setup Pane UI (operator pin) medium modified Manual inbox save now stamps mediatorDidSource: 'operator', making the value permanently immune to automated override.

📁 File Classifications

packages/extension/src/config.ts

  • Type: security

packages/extension/src/background.ts

  • Type: security

packages/extension/src/offscreen.ts

  • Type: security

packages/extension/src/setup-pane.tsx

  • Type: security

packages/extension/tests/wallet-inbox.test.mts

  • Type: test

🛡️ STRIDE Threat Model

Identified Threats (10)

🟠 STRIDE-1: DID Document Spoofing via followAgentInbox Re-resolution

Field Detail
Category Spoofing, Tampering, Information Disclosure
Severity High
Likelihood Likely
CVSS 7.7 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-345,CWE-346
CAPEC CAPEC-151,CAPEC-668
OWASP A08:2021 - Software and Data Integrity Failures

Description: followAgentInbox() in background.ts allows mediator DID hijacking via a spoofed or compromised DID document due to lack of authenticity verification on the resolved DID document response, resulting in silent redirection of the wallet's inbound DIDComm channel to an attacker-controlled mediator.

Evidence: packages/extension/src/background.ts:~1405-1470

async function followAgentInbox(): Promise<void> {
  const settings = await getSettings();
  if (settings.mediatorDidSource === "operator") return;
  const vtaDid = await readActiveVtaDid();
  ...
  await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
  await startInboundListener();

Attack Scenario:

  1. Attacker compromises or spoofs resolution of the active VTA's DID document (e.g., via DNS hijack, malicious webvh host, or compromised did:web resolver) referenced by readActiveVtaDid().
  2. On chrome.runtime.onStartup or onInstalled, followAgentInbox() in background.ts is invoked automatically without user interaction.
  3. followAgentInbox() calls handleRefreshVtaTransports({ type: RUNTIME_REFRESH_VTA_TRANSPORTS, vtaDid }), which resolves the DID document and extracts resp.result.mediatorDid.
  4. Because settings.mediatorDidSource !== 'operator' (default is agent or unset), the check if (settings.mediatorDidSource === 'operator') return; does not block the update.
  5. The attacker-controlled live mediator DID differs from settings.mediatorDid, so await setSettings({ mediatorDid: live, mediatorDidSource: 'agent' }) persists the attacker's mediator as trusted.
  6. startInboundListener() is invoked, reopening the DIDComm inbound channel against the attacker's mediator.
  7. All future RP/executor pushes intended for this wallet route through the attacker-controlled mediator, enabling interception, replay, or message tampering of DIDComm envelopes.

🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002, EP-004

  • Data Flows: DID document resolution -> mediatorDid extraction -> IndexedDB settings write -> inbound listener rebind

Preconditions: Attacker can influence or spoof DID document resolution for the currently active VTA (e.g., compromise of DID method resolver, webvh host, or MITM on resolution transport)., Wallet's mediatorDidSource is not operator (i.e., default or agent-adopted state).

Existing Controls: Operator-pinned inbox (mediatorDidSource === 'operator') is never overridden by this function. • Re-resolution occurs only on startup/install, not on every worker spin-up, limiting exposure window.

Recommended Mitigations: Verify DID document authenticity via DID method-specific cryptographic proofs (e.g., signed webvh log, DID:key self-certification) before trusting mediatorDid. • Require explicit user confirmation before silently migrating an agent-sourced inbox to a new value, at least on first migration per session. • Log and alert (not just console.info) on mediator DID changes so operators can detect unexpected relay migrations. • Pin DID resolution to a known-good transport (TLS-verified, DNSSEC, or signed VDR) to reduce spoofing surface.


🟡 STRIDE-2: One-Time Silent Migration Overwrite of Operator-Set Inbox in inboxToAdopt

Field Detail
Category Tampering, Elevation of Privilege
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-345,CWE-1188
CAPEC CAPEC-176
OWASP A08:2021 - Software and Data Integrity Failures

Description: inboxToAdopt() in config.ts allows a one-time silent overwrite of a legacy operator-configured mediatorDid due to indistinguishable provenance on records written before the mediatorDidSource field existed, resulting in unauthorized redirection of the wallet's inbox to the agent's advertised relay without operator consent.

Evidence: packages/extension/src/config.ts:~150-172

export function inboxToAdopt(
  current: { did?: string | undefined; source?: InboxSource | undefined },
  advertised: string | undefined,
): string | undefined {
  if (!advertised) return undefined;
  if (current.source === "operator") return undefined;
  if (current.did && current.source === "agen

Attack Scenario:

  1. A legacy wallet record exists in IndexedDB with mediatorDid set (from before mediatorDidSource existed) but no mediatorDidSource field, representing a deliberate but unattributed operator choice.
  2. Attacker (or a malicious/compromised agent) advertises a different mediator DID via the VTA's DID document.
  3. On next boot, startInboundListener() or doOnboardConnect() calls inboxToAdopt({ did: settings.mediatorDid, source: settings.mediatorDidSource }, advertised).
  4. Since current.source is undefined (not 'operator' and not ('did' && 'agent')), the function falls through to return advertised;, silently adopting the attacker/agent's mediator.
  5. setSettings({ mediatorDid: adopt, mediatorDidSource: 'agent' }) persists the change, and console.info is the only signal — easily missed by an operator.
  6. The wallet's inbox is now the attacker/agent's relay, and the operator's original deliberate choice is silently lost with no rollback path.
  7. This applies specifically and only once per legacy record, but attacker with agent/DID-document control can force this exact one-time migration to occur on a targeted wallet by ensuring it's still in the legacy no-provenance state when compromise occurs.

🔎 Threat Clue: Derived from COMP-002 via EP-007, EP-001, EP-005

  • Data Flows: Legacy IndexedDB settings record -> inboxToAdopt evaluation -> settings overwrite

Preconditions: Target wallet's settings record predates the mediatorDidSource field (or was otherwise created without provenance)., Attacker controls or can influence the DID document of the currently active VTA/agent.

Existing Controls: The overwrite occurs at most once per legacy record (subsequent writes stamp mediatorDidSource: 'agent', which then triggers the agent-sourced retention path). • Console logging (console.info) records the event, though not surfaced to the UI.

Recommended Mitigations: Require explicit operator re-confirmation (UI prompt) before migrating any legacy unattributed mediatorDid, rather than silent auto-adoption. • Emit a persistent, user-visible notification (not just console.info) when a stored mediator value is overwritten during migration. • Provide a rollback/audit log entry capturing the pre-migration value for operator review.


🟡 STRIDE-3: Unauthenticated Runtime Message Triggering handleRefreshVtaTransports

Field Detail
Category Spoofing, Tampering, Denial of Service
Severity Medium
Likelihood Possible
CVSS 6.9 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-306,CWE-400
CAPEC CAPEC-133,CAPEC-125
OWASP A01:2021 - Broken Access Control

Description: RUNTIME_REFRESH_VTA_TRANSPORTS message handler in background.ts allows unauthenticated triggering of DID re-resolution and inbox mediator mutation due to missing sender-origin validation on the chrome.runtime message listener, resulting in denial-of-service or forced mediator churn by any extension component or malicious content script.

Evidence: packages/extension/src/background.ts:~1470-1480

async function handleRefreshVtaTransports(
  req: RuntimeRefreshVtaTransportsRequest,
): Promise<RuntimeRefreshVtaTransportsResponse> {

Attack Scenario:

  1. A malicious or compromised extension page/content script (if the extension exposes externally_connectable or a vulnerable content script bridge) sends a RUNTIME_REFRESH_VTA_TRANSPORTS message.
  2. handleRefreshVtaTransports(req) is invoked without validating chrome.runtime.MessageSender origin/id, per EP-004 in recon (auth_required: false).
  3. The handler resolves the VTA DID document and may call setSettings({ mediatorDid: live, mediatorDidSource: 'agent' }) if the result differs from the current value, indirectly triggering followAgentInbox's write path.
  4. Repeated invocation from an attacker-controlled sender floods DID resolution network calls, creating resource exhaustion (DoS) or repeatedly flips the inbox mediator if an attacker can also influence resolution results (chaining with STRIDE-1).
  5. Because no rate-limiting or sender validation exists, this can be triggered at will by any code able to post a runtime message to the background service worker.

🔎 Threat Clue: Derived from COMP-001 via EP-004

  • Data Flows: chrome.runtime message -> handleRefreshVtaTransports -> DID resolution -> settings write

Preconditions: Attacker has code execution in a context able to send chrome.runtime.sendMessage to this extension's background worker (e.g., malicious content script if the extension's manifest permits broad host access, or a compromised sibling extension component).

Existing Controls: Message type namespacing (RUNTIME_REFRESH_VTA_TRANSPORTS) constrains the handler surface to a specific action. • No evidence of externally_connectable in provided files (undetermined from reduced source).

Recommended Mitigations: Validate chrome.runtime.MessageSender.id matches the extension's own ID for all internal runtime messages. • Apply rate-limiting/debouncing to handleRefreshVtaTransports to prevent resource exhaustion via repeated invocation. • Restrict message handler exposure to only trusted internal callers (e.g., via a nonce or session token passed from setup-pane.tsx).


🟡 STRIDE-4: Unencrypted IndexedDB Persistence of Mediator DID and Provenance

Field Detail
Category Tampering, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-311,CWE-732
CAPEC CAPEC-150,CAPEC-37
OWASP A02:2021 - Cryptographic Failures

Description: IndexedDBKVStore in config.ts allows local disclosure and tampering of the wallet's mediatorDid and mediatorDidSource due to storage without encryption at rest, resulting in exposure of routing metadata and trust-provenance state to any process with local browser profile access (malware, other extensions with storage access, or physical access).

Evidence: packages/extension/src/config.ts:~145-230

const SETTINGS_KEY = "pnm/settings/v1";
async function storedSettings(): Promise<Partial<WalletSettings>> {
  return (await new IndexedDBKVStore().get<Partial<WalletSettings>>(SETTINGS_KEY)) ?? {};
}

Attack Scenario:

  1. Attacker gains local access to the browser profile (e.g., malware, another malicious extension with storage permission overlap, or physical device access).
  2. Attacker reads the IndexedDB object store keyed pnm/settings/v1 directly from the browser's profile directory or via DevTools/extension inspection.
  3. mediatorDid and mediatorDidSource are read in plaintext, revealing the wallet's DIDComm routing configuration and whether it was operator-pinned or agent-adopted.
  4. Attacker with write access to the same IndexedDB store (e.g., via a co-installed malicious extension with storage permission collision, or a compromised offscreen/background context) directly writes a forged { mediatorDid: attackerRelay, mediatorDidSource: 'operator' } record.
  5. Because mediatorDidSource: 'operator' is now stamped, inboxToAdopt and followAgentInbox both treat this as permanently pinned and will never auto-correct it, even when the legitimate agent later advertises the correct relay.
  6. The wallet's inbox is permanently hijacked to the attacker's mediator, disguised as a deliberate operator choice, requiring manual operator intervention to detect and fix.

🔎 Threat Clue: Derived from COMP-002 via EP-007

  • Data Flows: IndexedDBKVStore.put/get on key 'pnm/settings/v1'

Preconditions: Attacker has local code execution or storage access within the same browser profile/origin partition as the extension., No integrity protection (e.g., signature, HMAC) exists on the stored settings blob.

Existing Controls: encryptHolderSecret exists as a distinct setting for holder secrets, implying awareness of sensitive-data handling, though not applied to mediatorDid/mediatorDidSource. • Chrome extension storage partitioning limits access to same-extension contexts under normal browser security model.

Recommended Mitigations: Sign or HMAC-protect the stored settings blob so writes from outside the extension's own write path are detectable. • Consider encrypting the settings record at rest, or at minimum the provenance field, to prevent silent tampering. • Add integrity verification (checksum comparison) on read in getSettings()/storedSettings() to detect out-of-band tampering.


🔵 STRIDE-5: Insufficient Logging of Mediator Migration Events for Repudiation Defense

Field Detail
Category Repudiation
Severity Low
Likelihood Likely
CVSS 3.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-778,CWE-223
CAPEC CAPEC-268
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: Mediator migration events in background.ts and offscreen.ts allow repudiation of unauthorized inbox changes due to reliance solely on ephemeral console.info/console.warn logging without persistent, tamper-evident audit trail, resulting in inability to forensically prove when or why a wallet's inbox mediator changed.

Evidence: packages/extension/src/background.ts:~1445-1465

await setSettings({ mediatorDid: live, mediatorDidSource: "agent" });
console.info(
  "[pnm inbound] the agent moved its relay:",
  settings.mediatorDid ?? "(none)",
  "→",
  live,
);

Attack Scenario:

  1. An inbox migration occurs (via followAgentInbox, startInboundListener backfill, or doOnboardConnect) that redirects the wallet's DIDComm inbox to a new mediator.
  2. The only record of this event is console.info/console.warn output to the browser's DevTools console, which is not persisted, not exported, and cleared on service worker restart (MV3 workers respawn frequently).
  3. If the migration was malicious (chained with STRIDE-1 or STRIDE-2) or simply operator-confusing, there is no durable evidence trail an operator or incident responder can review after the fact.
  4. An attacker who triggered an unauthorized migration can deny responsibility, and the operator cannot distinguish a legitimate agent-driven relay move from an attacker-induced one without console access captured in real time.
  5. Post-incident forensics is effectively impossible once the service worker has respawned, as MV3 workers do not persist console history.

🔎 Threat Clue: Derived from COMP-001, COMP-003 via EP-001, EP-002, EP-005

  • Data Flows: Mediator migration decision -> console.info/console.warn (ephemeral)

Preconditions: A mediator migration event occurs (benign or malicious)., No external logging/telemetry pipeline captures these console messages.

Existing Controls: Console logging exists at all migration decision points (informative, but non-durable). • Distinct log messages differentiate adoption, following, and unattributed-warning cases.

Recommended Mitigations: Persist mediator migration events (old value, new value, source, timestamp) to a durable, append-only audit log in IndexedDB or exported telemetry. • Surface migration events to the Setup UI so operators can review a history of inbox changes. • Include a cryptographic hash chain or monotonic counter on audit entries to detect log tampering/deletion.


🟡 STRIDE-6: Race Condition (TOCTOU) Between getSettings Read and setSettings Write in Inbox Migration

Field Detail
Category Tampering
Severity Medium
Likelihood Possible
CVSS 5.1 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-362,CWE-367
CAPEC CAPEC-26
OWASP A04:2021 - Insecure Design

Description: Concurrent invocation of startInboundListener, followAgentInbox, and doOnboardConnect allows a TOCTOU race condition due to non-atomic read-modify-write on the IndexedDB-backed settings store, resulting in lost updates or inconsistent mediatorDid/mediatorDidSource pairs.

Evidence: packages/extension/src/config.ts:~224-230

export async function setSettings(patch: Partial<WalletSettings>): Promise<void> {
  const stored = await storedSettings();
  await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, ...patch });
}

Attack Scenario:

  1. MV3 service worker respawns can trigger chrome.runtime.onStartup and chrome.permissions.onAdded/onRemoved listeners in overlapping or rapid succession, each independently calling getSettings() then later setSettings().
  2. Thread A (followAgentInbox) reads settings with mediatorDid=X, source=agent at time T0.
  3. Thread B (doOnboardConnect via offscreen.ts, e.g., from a concurrent onboarding retry) reads the same stored state at T0+ε and independently computes a different inbox value via inboxToAdopt.
  4. Thread B writes setSettings({ mediatorDid: Y, mediatorDidSource: 'agent' }) at T1.
  5. Thread A, still operating on its stale read, writes setSettings({ mediatorDid: Z, mediatorDidSource: 'agent' }) at T1+ε, silently overwriting Thread B's update since storedSettings()/setSettings() perform no optimistic locking or versioning.
  6. The final persisted state reflects whichever write occurred last, non-deterministically, potentially reverting a just-completed legitimate migration or, in adversarial conditions, allowing an attacker to win a race against a legitimate correction and have their forged value persist.

🔎 Threat Clue: Derived from COMP-001, COMP-002, COMP-003 via EP-001, EP-002, EP-005

  • Data Flows: Concurrent getSettings/setSettings on 'pnm/settings/v1'

Preconditions: Two or more mediator-migration code paths (startInboundListener backfill, followAgentInbox, doOnboardConnect) execute concurrently against the same IndexedDB record., No locking, versioning, or transactional guarantee in IndexedDBKVStore.get/put usage as shown.

Existing Controls: storedSettings() reads the raw stored record rather than the defaulted view, reducing (but not eliminating) one class of the original defect. • Migration paths are individually idempotent given a stable settings snapshot, limiting damage to lost-update rather than corruption.

Recommended Mitigations: Use IndexedDB transactions with read-modify-write atomicity (single transaction spanning get+put) rather than separate get() and put() calls. • Introduce a version/ETag field in WalletSettings and reject writes based on stale reads (optimistic concurrency control). • Serialize mediator-migration logic through a single mutex/queue in the background service worker to prevent concurrent execution of followAgentInbox/startInboundListener/doOnboardConnect.


🔵 STRIDE-7: Missing User Confirmation on Setup Pane Inbox Save Enables UI-Layer Trickery

Field Detail
Category Tampering, Elevation of Privilege
Severity Low
Likelihood Unlikely
CVSS 4.5 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-20,CWE-345
CAPEC CAPEC-98,CAPEC-163
OWASP A04:2021 - Insecure Design

Description: The Setup → Message routing save handler in setup-pane.tsx allows an operator to permanently pin an attacker-supplied mediator DID due to lack of input validation, format verification, or confirmation dialog on the free-text inbox field, resulting in a permanently trusted malicious relay that survives all future agent-driven corrections.

Evidence: packages/extension/src/setup-pane.tsx:~207-213

await setSettings({ mediatorDid: inbox.trim(), mediatorDidSource: "operator" });
setSavedInbox(inbox.trim());
setRoutingOpen(false);

Attack Scenario:

  1. Attacker uses social engineering (fake support instructions, phishing page mimicking VTA onboarding) to convince an operator to paste an attacker-controlled mediator DID string into the Setup pane's inbox field.
  2. Operator clicks Save; setSettings({ mediatorDid: inbox.trim(), mediatorDidSource: 'operator' }) executes with no format validation (e.g., no DID syntax check, no reachability probe) and no confirmation step.
  3. Because mediatorDidSource is now 'operator', both inboxToAdopt and followAgentInbox treat this as permanently pinned — if (settings.mediatorDidSource === 'operator') return; — meaning legitimate future agent relay changes will never auto-correct this.
  4. The wallet's inbound DIDComm channel is now durably routed to the attacker's mediator, requiring the victim to notice and manually revert via the same UI.
  5. All subsequent RP/executor pushes intended for the victim's wallet are interceptable at the attacker's mediator indefinitely.

🔎 Threat Clue: Derived from COMP-004 via EP-006

  • Data Flows: Setup pane form input -> setSettings write ('operator' pinned)

Preconditions: Attacker can socially engineer or trick the operator into entering a malicious DID string., No validation of DID syntax, resolvability, or reachability occurs before persisting as 'operator' source.

Existing Controls: Change is explicit and requires operator action (setBusy, form submission), providing some friction against fully automated attack. • The field is documented in-code as "the one place a person picks a relay," indicating deliberate design intent for operator control.

Recommended Mitigations: Validate DID syntax and, where feasible, resolvability of the mediator DID before allowing save. • Display a confirmation dialog summarizing the security implication of pinning a custom relay ("this wallet will always route inbound messages through this relay"). • Provide an in-UI way to view and revert to the last agent-advertised mediator for comparison before committing an operator override.


🔵 STRIDE-8: Denial of Service via Repeated followAgentInbox DID Resolution Failures

Field Detail
Category Denial of Service
Severity Low
Likelihood Possible
CVSS 3.8 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-400,CWE-405
CAPEC CAPEC-125,CAPEC-227
OWASP A05:2021 - Security Misconfiguration

Description: followAgentInbox() in background.ts allows availability degradation of the inbound DIDComm listener due to unhandled repeated resolution failures on every browser startup/update without backoff, resulting in wasted network calls and delayed listener readiness for legitimate inbound messages.

Evidence: packages/extension/src/background.ts:~1425-1435

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) {
  console.warn("[pnm inbound] could not re-resolve the agent's relay:", e);
  return;
}

Attack Scenario:

  1. Attacker controls or disrupts the network path/host serving the active VTA's DID document (e.g., DNS blackhole, TLS termination failure, rate-limiting the resolver endpoint).
  2. Every chrome.runtime.onStartup and chrome.runtime.onInstalled event triggers followAgentInbox(), which calls handleRefreshVtaTransports() and thus attempts DID resolution.
  3. The resolution consistently throws, hitting the catch (e) branch: console.warn("[pnm inbound] could not re-resolve the agent's relay:", e); return;.
  4. No exponential backoff, circuit breaker, or failure-count tracking exists; each browser restart/update repeats the same expensive resolution attempt.
  5. If resolution is slow (e.g., attacker-induced timeout rather than fast failure), this delays startInboundListener()'s effective readiness on every boot, degrading availability of inbound message reception during the resolution window.
  6. Combined with MV3 service worker respawn frequency, an attacker who can reliably stall (not just fail) DID resolution can keep the wallet's inbound listener perpetually delayed.

🔎 Threat Clue: Derived from COMP-001 via EP-001, EP-002

  • Data Flows: chrome.runtime.onStartup/onInstalled -> followAgentInbox -> handleRefreshVtaTransports -> DID resolution (network)

Preconditions: Attacker can influence network reachability or latency of the DID document resolution endpoint for the active VTA., No timeout/backoff safeguard exists around the resolution call chain.

Existing Controls: Failure path preserves the existing (last-known-good) mediator rather than clearing it, preventing total inbox loss on resolution failure. • Resolution only runs on startup/install, not per worker spin-up, limiting frequency of exposure.

Recommended Mitigations: Add a bounded timeout to the DID resolution call within handleRefreshVtaTransports invoked from followAgentInbox. • Implement exponential backoff / failure counters to avoid repeated expensive resolution attempts on persistent failure. • Decouple startInboundListener() invocation from followAgentInbox() completion so a slow/failed resolution does not block listener readiness.


🔵 STRIDE-9: Type Confusion in Legacy IndexedDB Record Bypassing mediatorDidSource Validation

Field Detail
Category Tampering
Severity Low
Likelihood Unlikely
CVSS 3.5 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-20,CWE-704
CAPEC CAPEC-136
OWASP A08:2021 - Software and Data Integrity Failures

Description: getSettings() in config.ts allows injection of an invalid mediatorDidSource value due to the type guard only accepting exact string matches without rejecting or sanitizing malformed stored records, resulting in ambiguous provenance state if an attacker writes a crafted IndexedDB record with an unexpected mediatorDidSource value.

Evidence: packages/extension/src/config.ts:~195-200

...(s?.mediatorDidSource === "agent" || s?.mediatorDidSource === "operator"
  ? { mediatorDidSource: s.mediatorDidSource }
  : {}),

Attack Scenario:

  1. Attacker with local write access to the extension's IndexedDB (e.g., via a compromised co-installed extension able to access the same storage partition, or direct DevTools manipulation) writes { mediatorDid: 'did:attacker:relay', mediatorDidSource: 'admin' } (an invalid enum value) directly to the pnm/settings/v1 key.
  2. getSettings() evaluates s?.mediatorDidSource === 'agent' || s?.mediatorDidSource === 'operator', which is false for 'admin', so the field is silently dropped from the returned defaulted view (treated as unset).
  3. However, storedSettings() (used internally by setSettings) returns the RAW stored record including the invalid mediatorDidSource: 'admin' value, since it does no filtering.
  4. Downstream code paths that call getSettings() see no mediatorDidSource, triggering the 'unattributed, adopt' logic in inboxToAdopt/followAgentInbox — but mediatorDid itself (did:attacker:relay) IS preserved by getSettings()'s spread since it only checks truthiness, not source validity.
  5. Depending on call order, this could either cause the malicious relay to be silently adopted-over (if a legitimate agent relay is available) or persist as the active mediatorDid with no clear source designation, creating ambiguous trust state that operators cannot audit via the UI (which likely displays mediatorDidSource for clarity).

🔎 Threat Clue: Derived from COMP-002 via EP-007

  • Data Flows: Raw IndexedDB record -> storedSettings (unfiltered) vs getSettings (filtered) -> inboxToAdopt

Preconditions: Attacker has direct write access to the extension's IndexedDB storage outside the normal setSettings() API path., The crafted record uses a mediatorDidSource value outside the InboxSource union type.

Existing Controls: getSettings() defensively validates mediatorDidSource against the exact allowed literal values before including it in the defaulted view. • TypeScript's type system prevents this at the application's own call sites (though not against raw storage tampering, which bypasses type checking entirely).

Recommended Mitigations: Add explicit rejection/sanitization of unrecognized mediatorDidSource values in storedSettings() as well, not just getSettings(). • Validate the entire settings object against a schema (e.g., zod/io-ts) on every read from IndexedDB to reject malformed records outright. • Consider treating a malformed/unrecognized mediatorDidSource as equivalent to 'operator' (fail-safe/restrictive) rather than falling through to unattributed-adopt logic.


⚪ STRIDE-10: Silent Failure Mode on Unattributed Inbox with No VTA Advertisement Leaves Wallet Unreachable

Field Detail
Category Denial of Service, Information Disclosure
Severity Informational
Likelihood Likely
CVSS 1.8 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
Residual Severity None
CWE CWE-756,CWE-390
CAPEC CAPEC-664
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: startInboundListener() in background.ts allows persistent unreachability of the wallet's inbound DIDComm channel due to the warning-only fallback when no mediator can be adopted or attributed, resulting in silent message-delivery failure that presents as a false-positive healthy state to the user.

Evidence: packages/extension/src/background.ts:~500-512

} else if (!settings.mediatorDidSource && vtaDids.length > 0) {
  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.",
  );
}

Attack Scenario:

  1. Wallet boots with mediatorDidSource unset and no VTA (vtaDids.length === 0 or resolution otherwise fails to produce an advertised mediator).
  2. startInboundListener() executes the else-branch: console.warn("[pnm inbound] inbox relay is unattributed and no onboarded agent advertises one to adopt...").
  3. This warning is only visible in the browser DevTools console for the extension's background context, which the average operator never opens.
  4. The wallet continues to appear operational in the popup/setup UI (assuming no UI-level surfacing of this specific warning), giving a false sense that inbound messages will be received.
  5. An RP or executor attempts to push a DIDComm message to this wallet's (nonexistent/stale) inbox and the delivery silently fails or is dropped by the mediator infrastructure, with no feedback loop to either party.
  6. This is a design/availability gap rather than a directly exploitable vulnerability, but an attacker aware of this behavior could specifically target wallets in this state (e.g., freshly reset or migrated wallets) knowing inbound delivery is guaranteed to fail, to mask a simultaneous phishing/social-engineering attempt requesting the victim manually configure a malicious relay 'to fix connectivity.'

🔎 Threat Clue: Derived from COMP-001 via EP-001

  • Data Flows: startInboundListener -> unattributed warning (console only)

Preconditions: Wallet has no attributed mediator and no VTA to adopt from., Operator does not proactively check DevTools console or Setup pane diagnostics.

Existing Controls: runDiagnostics (referenced in offscreen.ts comments) is stated to report this state, providing at least one discoverable surface. • The design explicitly treats unset as "the honest state" rather than fabricating a default, avoiding a worse false-positive of pointing to a wrong relay.

Recommended Mitigations: Surface the unattributed-inbox warning prominently in the Setup pane UI, not only in DevTools console. • Add a periodic self-check/notification (e.g., badge icon change) when the wallet has been in the unattributed state beyond a threshold duration. • Provide one-click remediation (e.g., a 'Fix inbox routing' button) directly from the warning surface.



🍝 PASTA Threat Model

Application Purpose

A Chrome Manifest V3 browser extension implementing a DIDComm-based wallet ('holder') that receives verifiable credential and step-up-authentication messages via a mediator relay advertised by an associated verifiable trust agent (VTA), providing decentralized identity wallet functionality to end users.

Inherent Risks

  • The wallet has no independent discovery mechanism (did:key holders lack service endpoints), making inbound message delivery entirely dependent on trusting a third-party-advertised mediator DID.
  • MV3 service worker lifecycle (frequent respawn) increases the number of code paths that must independently and consistently derive trust state from persisted settings.
  • IndexedDB storage has no built-in confidentiality or integrity guarantees against other local actors sharing the browser profile.

Objectives

Risk: Accept a bounded, one-time, and clearly-scoped risk of automatic migration for legacy records lacking provenance, in exchange for eliminating a broader class of stuck-wallet failures.
Business: Provide a trustworthy decentralized identity wallet experience for end users interacting with relying parties and verifiable trust agents.
Security: Prevent unauthorized redirection of the wallet's inbound message channel to an attacker-controlled relay.; Preserve deliberate operator configuration choices against silent automated override.
Financial: Avoid liability and remediation costs from wallet compromise incidents affecting credential holders.
Compliance: Maintain data integrity and auditability expectations consistent with decentralized identity trust frameworks (e.g., DIDComm, W3C VC) that these wallets participate in.
Functional: Reliably receive DIDComm-pushed messages (credentials, step-up authentication requests) via a correctly-routed inbox mediator.
Operational: Ensure inbox mediator configuration remains consistent and correctly attributed across browser restarts, extension updates, and onboarding flows.

Business Impact Analysis (2)

BIA-1: Inbound Credential and Step-Up Message Delivery (High)

The end-to-end process by which relying parties and executors push DIDComm-encoded verifiable credentials or step-up authentication requests to a user's wallet via the mediator relay recorded in wallet settings.

MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: Extension Developers / Mediator Relay Operators / Relying Parties (RPs) / Verifiable Trust Agent (VTA) Operators / Wallet Operators (End Users)
  • Dependencies: Chrome Extension Runtime (MV3) / DID Document Resolution Infrastructure / DIDComm Mediator Relay Service / IndexedDBKVStore (@openvtc/pnm-core)
  • Disruptions: Attacker-controlled or compromised DID document resolution redirecting the mediator / Legitimate mediator migration racing with a concurrent onboarding write / Mediator relay outage or unreachability / Silent unattributed-inbox state leaving the wallet permanently unreachable
  • Impacts: Credential delivery failure requiring manual operator remediation / Interception or tampering of DIDComm-pushed step-up authentication requests, enabling account takeover of downstream RP sessions / Loss of user trust in the wallet's reliability / Potential regulatory scrutiny if step-up authentication bypass leads to unauthorized transaction approval

BIA-2: Operator-Initiated Inbox Mediator Configuration (Medium)

The process by which a wallet operator deliberately sets or overrides the inbox mediator DID via the Setup pane's Message routing section, expected to persist as the authoritative, non-overridden configuration.

MTD: 07 days 00:00 hours | RTO: 01 days 00:00 hours | RPO: 00 days 00:00 hours

  • Stakeholders: Setup Pane UI Maintainers / Wallet Operators (End Users)
  • Dependencies: IndexedDBKVStore (@openvtc/pnm-core) / React Setup Pane Component (setup-pane.tsx)
  • Disruptions: Social engineering leading operator to pin a malicious mediator DID / Lack of validation allowing malformed or unreachable DIDs to be saved
  • Impacts: Permanent (until manual correction) redirection of inbound messages to an attacker relay / Operator confusion when the pinned value silently diverges from the agent's advertised relay

Technical Scope

Roles (3): RO-1 Wallet Operator · RO-2 Verifiable Trust Agent Operator · RO-3 Extension Background Process

Actors (4): AC-1 Wallet Operator (Human) · AC-2 Background Service Worker · AC-3 Offscreen Onboarding Process · AC-4 Verifiable Trust Agent

Use Cases (3): Automatic Inbox Mediator Adoption on Onboarding · Operator-Initiated Mediator Pinning · Boot-Time Mediator Following

Attack Trees (4): SC-1: Background Service Worker · SC-2: Wallet Settings Store (config.ts) · SC-3: IndexedDB Persistent Store · SC-5: Setup Pane UI

Entry Points (7): EP-001 Browser Startup Event · EP-002 Extension Install/Update Event · EP-003 Permissions Change Event · EP-004 Refresh VTA Transports Runtime Message · EP-005 Onboarding Connect Function · EP-006 Setup Pane Save Inbox Handler · EP-007 Inbox Adoption Decision Function

Risk Registry (7): RISK-001 · RISK-002 · RISK-003 · RISK-004 · RISK-005 · RISK-006 · RISK-007

Threat Actors (3): TA-1 Malicious DID Resolution Infrastructure Operator · TA-2 Local Browser Profile Malware / Co-Installed Malicious Extension · TA-3 Social Engineer

Infrastructure (1): IF-1 Browser Extension Client Runtime

Trust Boundaries (3): TB-1 Browser Extension Runtime Boundary · TB-2 External DID Resolution Network · TB-3 Operator/User Interaction Boundary

External Entities (3): EE-1 Relying Party / Executor · EE-2 Verifiable Trust Agent (VTA) · EE-3 DID Document Host/Resolver

System Components (7): SC-1 Background Service Worker · SC-2 Wallet Settings Store (config.ts) · SC-3 IndexedDB Persistent Store · SC-4 Offscreen Onboarding Document · SC-5 Setup Pane UI · SC-6 VTA DID Document Resolver · SC-7 DIDComm Mediator Relay

Resources And Assets (3): RA-1 Wallet Settings Record (pnm/settings/v1) · RA-2 Active VTA DID Document · RA-3 Inbound DIDComm Message Channel

Technologies And Dependencies (3): TD-1 @openvtc/pnm-core (IndexedDBKVStore) · TD-2 Chrome Extension Manifest V3 APIs · TD-3 React

⚔️ Attack Scenarios (1)

Exploit identified weaknesses

flowchart LR
  S0["DID Document Spoofing via followAgentInbox Re-resolution"]
  S1["One-Time Silent Migration Overwrite of Operator-Set Inbox in"]
  S2["Unauthenticated Runtime Message Triggering handleRefreshVtaT"]
  S0 --> S1
  S1 --> S2
Loading

📊 Risk Summary

Total Threats: 10

By Severity: Low: 4 · High: 1 · Medium: 4 · Informational: 1

By Category: Spoofing: 2 · Tampering: 7 · Information Disclosure: 3 · Elevation of Privilege: 2 · Denial of Service: 3 · Repudiation: 1


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

📊 Summary & findings
✅ Confirmed ⚠️ Must-Review-By-Human
3 3

Confirmed (3)

  • 🟡 inboxToAdopt() silently overwrites legacy unattributed operator-chosen mediator without confirmation
  • 🟡 Mediator DID and provenance persisted in unencrypted, integrity-unprotected IndexedDB store
  • 🟡 Race condition (TOCTOU) between reading and writing wallet settings allows lost updates

Must-Review-By-Human (3)

  • 🟡 Mediator DID adopted from unauthenticated DID document without integrity verification (relay hijack) (triaged HIGH→MEDIUM)
  • 🟡 Internal runtime message handler lacks sender-origin validation and rate limiting (potential DoS / forced re-resolution)
  • 🔵 Unsafe Formatstring (3 occurrences)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants