Skip to content

fix(inbox): one inbox per agent, so every agent can reach the wallet - #150

Merged
stormer78 merged 1 commit into
mainfrom
fix/per-agent-inboxes
Aug 31, 2026
Merged

fix(inbox): one inbox per agent, so every agent can reach the wallet#150
stormer78 merged 1 commit into
mainfrom
fix/per-agent-inboxes

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Closes the limitation flagged on #149. Glenn runs multiple VTAs, so this was live, not theoretical.

The defect

The wallet had one mediatorDid for its whole inbox. Whichever agent that value happened to name could push to it. Every other onboarded agent's consent requests and approvals were handed to a relay the wallet was not listening on, and lost without a trace — no error, no failed check, nothing in the self-test.

Why it has to be per agent: a v4 holder is a did:key, which carries no service endpoint, and the wallet publishes its relay to nobody — device/set-wake's suggestedTriggers is advisory and never carries it. There is no discovery path at all. So an executor can only hand a message to a mediator it already knows, its own, and the wallet hears it only if it happens to be listening there. An inbox is not one address the wallet owns; it is "wherever that agent's relay is", once per agent.

The change

settings.inboxes is Record<vtaDid, { did, source }>.

  • Onboarding writes that agent's entry — agent when advertised, operator when supplied in answer to MediatorRequiredError, since a person chose it.
  • reconcileInbound opens one session per (agent, that agent's relay).
  • isInbox, the close-extras sweep and the transport-health snapshot match on the pair. With one relay per agent the same mediator DID can be one agent's inbox and another's outbound hop, so a single-DID comparison would both mislabel sessions and close the wrong ones.
  • followAgentInbox sweeps every agent, not just the active one. One reconcile after the sweep rather than one per agent.
  • The approver inbox had the same defect — the session that carries task-consent/request was keyed on the wallet-wide value too, so a second agent's approval prompts could never arrive (R7.2).

Migration

Wallets on the old single setting:

  • operator-sourced → carried over to the active agent, the one they were looking at when they typed it.
  • Anything else → dropped, not spread across every agent. It was most likely the removed hardcoded demo relay, which the old setSettings persisted as though it had been chosen.

The per-agent adopt then fills each entry from that agent's persisted connection, so no re-onboarding. The legacy keys are cleared so it runs once.

Two ordering traps worth review

  • setInbox / forgetInbox own the read-modify-write. Handing setSettings a whole inboxes object drops every agent absent from the caller's copy — and the symptom is another agent's pushes going quietly nowhere, which is the failure this map exists to end.
  • An agent's entry is forgotten inside the reconcile, right after its session closes. Deleting it where the operator forgets the agent runs before the reconcile, leaving the session unrecognisable as an inbox and therefore open forever.

UI and diagnostics

Setup's routing field edits the active agent's relay and says so — it previously wrote one value for the whole wallet, which is what made every other agent unreachable. The self-test reports a missing relay per agent, and warns when a pinned relay is not what that agent advertises: a wallet listening somewhere the agent does not push is listening where nothing arrives.

getWalletMediatorDid is deleted rather than left reading the deprecated field.

Verification

Lint, build and 682 tests pass. The inbox test file was rewritten around the per-agent shape; the assertion that matters is that a wallet onboarded at two agents on two relays reads both.

Not verified by me: the live multi-VTA path. Worth running the self-test against each of your agents in turn — each should now name its own relay, and none should name another's.

Pre-merge checklist

- [x] No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2) — no new fetches; followAgentInbox reuses the existing refresh path
- [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 writes are 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) — per-agent backoff unchanged; an agent with no relay is deliberately not put on it (config, not outage)
- [x] Acks/deletes happen only after durable handoff (R1.6) — inbound handler untouched; the drain resolves its relay per entry's own agent
- [x] New/changed wire types: camelCase, deny_unknown_fields where security-relevant, schema registered, all consumers (incl. JS) updated (R3.*) — no wire types; the settings map is validated per entry on read
- [x] Config absence = most restrictive; fail-closed if enforcement can't start (R5.*) — a missing entry means that agent cannot reach the wallet, reported per agent rather than defaulted
- [x] Logs/status claim only what was verified; background-job failures are surfaced (R6.*) — the self-test names each agent's relay and warns on a mismatch
- [x] "Process dies on the next line" answered for every mutation touched (R2.1) — each write is a single idempotent put; a death before it re-runs the adopt next boot
- [x] Deviations from this guide flagged explicitly with rule numbers — none

The wallet had a single `mediatorDid` for its whole inbox. Whichever
agent that value happened to name could push to it; every other
onboarded agent's consent requests and approvals went to a relay the
wallet was not listening on, and were lost without a trace. Glenn runs
multiple VTAs, so this was live.

Why it has to be per agent: a v4 holder is a `did:key`, which carries no
service endpoint, and the wallet publishes its relay to nobody — there is
no discovery path at all. An executor can therefore only hand a message
to a mediator it already knows, its own, and the wallet hears it only if
it is listening there. An inbox is not one address the wallet owns; it is
"wherever that agent's relay is", once per agent.

`settings.inboxes` is now `Record<vtaDid, { did, source }>`. Onboarding
writes the agent's entry; `reconcileInbound` opens one session per
(agent, that agent's relay); `isInbox`, the close-extras sweep and the
transport-health snapshot all match on the PAIR, because with one relay
per agent the same mediator can be one agent's inbox and another's
outbound hop. `followAgentInbox` sweeps every agent, not just the active
one. The approver inbox — the session that carries task-consent requests
— was keyed on the wallet-wide value too, so it had the same defect.

Migration for wallets on the old single setting. An `operator` one was a
person's choice and carries over to the active agent, the one they were
looking at when they typed it. Anything else is dropped rather than
spread across every agent: it was most likely the removed hardcoded demo
relay, which the old `setSettings` persisted as though it had been
chosen. The per-agent adopt then fills each entry from that agent's
persisted connection, so no re-onboarding is needed.

`setInbox` / `forgetInbox` own the read-modify-write of the map, because
handing `setSettings` a whole `inboxes` object drops every agent absent
from the caller's copy — and the symptom of that is another agent's
pushes going quietly nowhere, which is the failure this map exists to
end. An agent's entry is forgotten inside the reconcile, right after its
session is closed: deleting it where the operator forgets the agent runs
BEFORE the reconcile and leaves the session unrecognisable as an inbox,
and so open forever.

Setup's routing field now edits the active agent's relay and says so.
The self-test reports a missing relay per agent, and warns when a pinned
one is not what the agent advertises — a wallet listening somewhere the
agent does not push is listening where nothing arrives.

`getWalletMediatorDid` is deleted rather than left reading the deprecated
field.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit e14528b into main Aug 31, 2026
3 checks passed
@stormer78
stormer78 deleted the fix/per-agent-inboxes branch August 31, 2026 06:44
stormer78 added a commit that referenced this pull request Aug 31, 2026
The invariant behind #148#150, written down where the next person to
touch `reconcileInbound` will find it. Without it a map where a string
would do reads as over-built, and collapsing it back is a one-line change
whose only symptom is one agent's consent requests quietly never
arriving.

The load-bearing fact is not obvious from any single file: a v4 holder is
a `did:key` with no service endpoint and the wallet publishes its relay
to nobody, so there is no discovery path — an executor pushes through the
relay IT knows, and the wallet hears it only if it is listening there.
Everything else (the pair keying, the provenance field, the two
orderings) follows from that and looks arbitrary without it.

Also corrects the intro: the wallet runs one inbound session per
onboarded agent, not "the" session.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Aug 31, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

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

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #150

Field Value
Repository OpenVTC/vta-browser-plugin
Branch fix/per-agent-inboxesmain
Validated 2026-09-05
Scan ID 16b18e2b
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 1 · with findings: 1 · files: 9 · findings: 8

Module Files scanned Findings
packages/extension 9 8

Executive Summary

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

⚠️ 4 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)

🟡 Unauthenticated/unvalidated adoption of attacker-controlled mediatorDid from chrome.storage.local

Field Detail
Severity MEDIUM
Location packages/extension/src/active-vta.ts:20
Finding ID github_pr-1a52b21cb2bb
CWE CWE-20, CWE-345, CWE-494
OWASP A08:2021 - Software and Data Integrity Failures
MITRE ATT&CK T1565.001 - Data Manipulation: Stored Data Manipulation
CAPEC CAPEC-176, CAPEC-693
DREAD 6
Reachability 🔴 Reachable
Exploit Maturity poc
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — Scanner-confirmed reachability (is_reachable=true, explicit attack path EP-005 through parseAgentMediatorDids -> adoptMissingInboxes -> setInbox -> offscreen session) and no auth barrier support HIGH severity. However, exploitation requires an initial storage-write primitive that is not itself remotely/network exploitable without a precondition, and there is no confirmed CVE/EPSS/CISA KEV or working public exploit — only a scanner-inferred 'poc' based on code complexity. This falls short of CRITICAL gating criteria (no confirmed real-world exploitation, no CVSS≥9 basis, no unauthenticated remote trigger), but clearly meets HIGH: reachable code path, no cryptographic verification, and severe business impact (potential wallet takeover) if the precondition is met.
  • Composite score: 5.1
  • Environment: unknown

Summary: The extension trusts any string-typed mediatorDid found in chrome.storage.local's cached connection state without validating its format or verifying it actually originates from the agent's real DID document, allowing a tampered storage value to silently become the wallet's trusted inbox relay.

📝 Description:

An attacker who can write to this extension's chrome.storage.local can cause the wallet to route all DIDComm inbound traffic for a given agent (step-up authorization requests, credential offers) through a relay they control, enabling interception, tampering, or dropping of sensitive identity/authorization messages.

🧪 Proof of Concept:

The only validation applied to an externally-influenceable storage value is a non-empty string check. There is no DID format validation and no cryptographic tie-back to the actual agent's advertised mediator, so any value written into this storage key by any code sharing extension storage access is trusted.

export function parseAgentMediatorDids(raw: unknown): Record<string, string> {
  if (typeof raw !== "string") return {};
  try {
    const parsed = JSON.parse(raw) as {
      state?: { connections?: { vtas?: Record<string, { mediatorDid?: unknown }> } };
    };
    const out: Record<string, string> = {};
    for (const [vtaDid, entry] of Object.entries(parsed.state?.connections?.vtas ?? {})) {
      if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) {
        out[vtaDid] = entry.mediatorDid;
      }
    }
    return out;
  } catch {
    return {};
  }
}

Vulnerable lines: 20, 36

🔎 Evidence: packages/extension/src/active-vta.ts:20

export function parseAgentMediatorDids(raw: unknown): Record<string, string> {
  if (typeof raw !== "string") return {};
  try {
    const parsed = JSON.parse(raw) as {...};
    const out: Record<string, string> = {};
    for (const [vtaDid, entry] of Object.entries(parsed.state?.connections?.vtas ?? {})) {
      if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) {
        out[vtaDid] = entry.mediatorDid;
      }
    }
    return out;
  } catch { return {}; }
}

💥 Impact:

An attacker who can write to this extension's chrome.storage.local can cause the wallet to route all DIDComm inbound traffic for a given agent (step-up authorization requests, credential offers) through a relay they control, enabling interception, tampering, or dropping of sensitive identity/authorization messages.

Confidentiality: High — all DIDComm messages destined for the affected agent (step-up requests, credential offers, executor pushes) can be intercepted by an attacker-controlled relay · Integrity: High — attacker relay can inject or tamper with messages before/instead of forwarding to the legitimate wallet · Availability: Low — legitimate messages routed to attacker relay are effectively lost to the real wallet

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: none
  • Attack path: EP-005 (chrome.storage.local.get('pnm-connection/v3')) → active-vta.ts:readAgentMediatorDids/parseAgentMediatorDids → background.ts:adoptMissingInboxes → config.ts:setInbox → offscreen inbound session opened against adopted relay

⚖️ Triage Factors:

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

Attack scenario: An attacker with any means of writing to chrome.storage.local (compromised co-located extension, malicious dependency, or corrupted update) plants a fake mediatorDid that the wallet silently adopts as its inbox relay on next boot.

🔧 Remediation:

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

Add strict DID syntax validation and, more importantly, cryptographically verify the mediator DID against a signed DID document resolution rather than trusting whatever is cached in chrome.storage.local, which can be written by any code with storage access in this origin.

Vulnerable code:

if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) {
  out[vtaDid] = entry.mediatorDid;
}

Secure code:

const DID_SYNTAX = /^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/;
if (typeof entry?.mediatorDid === "string" && DID_SYNTAX.test(entry.mediatorDid)) {
  // Additionally require a verifiable proof/signature chain from the
  // agent's DID document rather than trusting cached local storage.
  if (verifyMediatorProvenance(vtaDid, entry.mediatorDid)) {
    out[vtaDid] = entry.mediatorDid;
  }
}

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: active-vta.ts lines 20-36 show parseAgentMediatorDids performing only typeof entry?.mediatorDid === "string" && entry.mediatorDid checks before returning the value as trusted mediator DID: 'for (const [vtaDid, entry] of Object.entries(parsed.state?.connections?.vtas ?? {})) { if (typeof entry?.mediatorDid === "string" && entry.mediatorDid) { out[vtaDid] = entry.mediatorDid; } }'. This is read from chrome.storage.local (readAgentMediatorDids) with no DID-syntax validation, n
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Legacy relay migration binds operator-pinned relay to wrong agent based on current active VTA

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts:485
Finding ID github_pr-26db294edaa7
CWE CWE-694, CWE-668
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1565.001 - Data Manipulation: Stored Data Manipulation
CAPEC CAPEC-176
DREAD 3.6
Reachability 🔴 Reachable
Exploit Maturity theoretical
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • The code evidence confirms the flaw is reachable and real (background.ts:485-497), but exploitability is explicitly assessed as low by the scanner because it requires a specific sequence of legitimate operator actions rather than attacker-controlled input; there is no network exposure and no public exploit class (it's an app-specific logic bug, not e.g. SQLi/XSS). No CVSS, no CWE, no exploit maturity beyond 'theoretical'. This does not meet the HIGH gate (needs exploitability≥5 or public exploit≥7) nor the CRITICAL gate. Medium is appropriate: reachable, real business impact (misrouted DIDComm traffic), but low exploitability and self-triggered rather than attacker-triggered.
  • Composite score: 5.3
  • Environment: production

Summary: A one-time legacy settings migration in adoptMissingInboxes() infers which agent should inherit an operator-pinned relay by reading the CURRENTLY active VTA at migration time, which can differ from the agent the operator actually intended when they originally set the pin, resulting in a permanent, uncorrectable misbinding.

📝 Description:

A specific onboarded agent may have its inbound DIDComm traffic permanently routed through a relay the operator never intended for it, with no automatic remediation since operator-sourced bindings are excluded from the auto-follow logic.

🧪 Proof of Concept:

readActiveVtaDid() is read at migration time rather than the time the operator originally chose the pin, so any active-VTA change in between causes the pinned relay to be attributed to the wrong agent permanently (source='operator' blocks future auto-correction).

async function adoptMissingInboxes(vtaDids: readonly string[]): Promise<void> {
  const settings = await getSettings();
  const advertised = await readAgentMediatorDids();

  if (settings.mediatorDid || settings.mediatorDidSource) {
    const activeVtaDid = await readActiveVtaDid();
    if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) {
      await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" });
      console.info("[pnm inbound] carried the pinned relay over to", activeVtaDid);
    }
    await clearLegacyInbox();
  }
  ...
}

Vulnerable lines: 478, 497

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

if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) {
  await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" });
  console.info("[pnm inbound] carried the pinned relay over to", activeVtaDid);
}

💥 Impact:

A specific onboarded agent may have its inbound DIDComm traffic permanently routed through a relay the operator never intended for it, with no automatic remediation since operator-sourced bindings are excluded from the auto-follow logic.

Confidentiality: Low-Medium — misrouted agent's traffic goes through an unintended relay, which may be less trusted · Integrity: Low — no attacker action required, but result is a data-integrity/logic error with security consequences · Availability: Low — misrouted agent's original intended relay is never bound because source is permanently 'operator'

🧭 Reachability:

  • Network exposure: none
  • Auth barrier: none
  • Attack path: Config UI (operator sets legacy mediatorDid, source=operator) → onboard second agent, switch active VTA → background.ts boot → adoptMissingInboxes() reads readActiveVtaDid() → setInbox(wrongVtaDid, {source:'operator'})

⚖️ Triage Factors:

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

Attack scenario: An operator pins a custom relay for one agent, then onboards and switches to a second agent before the next boot; the migration logic silently and permanently binds the first agent's pinned relay to the second, unrelated agent.

🔧 Remediation:

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

Instead of silently inferring the target agent from current active-VTA state (which can drift between when the pin was made and when migration runs), require explicit operator confirmation, or persist the vtaDid context at the moment the legacy pin was originally set.

Vulnerable code:

if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) {
  await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" });
}

Secure code:

// Require the operator to explicitly confirm which agent inherits the
// pinned relay during a one-time migration prompt, rather than inferring
// it from current active-VTA state which may have changed since the pin
// was set.
const confirmedVtaDid = await promptOperatorForMigrationTarget(settings.mediatorDid);
if (settings.mediatorDidSource === "operator" && settings.mediatorDid && confirmedVtaDid) {
  await setInbox(confirmedVtaDid, { did: settings.mediatorDid, source: "operator" });
}

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: background.ts lines 485-497 per evidence: 'if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) { await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" }); console.info(...) }' — this binds the legacy pinned relay to whatever activeVtaDid currently is, not necessarily the agent it was configured for. readActiveVtaDid() (active-vta.ts, provided) simply returns the CURRENTLY active VTA from storage with no historical/temp
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Non-atomic read-modify-write on settings.inboxes enables lost-update race across concurrent worker invocations

Field Detail
Severity MEDIUM
Location packages/extension/src/config.ts:1
Finding ID github_pr-2e6609754757
CWE CWE-362, CWE-367
OWASP A04:2021 - Insecure Design
MITRE ATT&CK T1499 - Endpoint Denial of Service
CAPEC CAPEC-25
Reachability 🔴 Reachable
Exploit Maturity theoretical
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • No CVSS/CVE exists (first-party logic flaw). Evidence supports a genuine race condition (confirmed via code review: read-then-put with spread merge, no lock/CAS/transaction), but impact is confined to integrity of a single local user's inbox mapping in a browser extension — no confidentiality/availability impact, no cross-user blast radius, and exploitation requires a timing coincidence between async triggers rather than attacker-controlled input. This matches medium: CWE-362-class flaw, reachable, but low severity impact ceiling and non-deterministic trigger, not high/critical territory.
  • Composite score: 5.3
  • Environment: production

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

export async function setInbox(vtaDid: string, record: InboxRecord): Promise<void> {
  const stored = await storedSettings();
  await new IndexedDBKVStore().put(SETTINGS_KEY, {
    ...stored,
    inboxes: { ...(stored.inboxes ?? {}), [vtaDid]: record },
  });
}

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: none
  • Attack path: EP-001/EP-002/EP-003 (multiple MV3 wake triggers) → concurrent adoptMissingInboxes()/followAgentInbox() invocations → config.ts:setInbox() get/put race → lost update on inboxes map

🔧 Remediation:

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

Vulnerable code:

export async function setInbox(vtaDid: string, record: InboxRecord): Promise<void> {
  const stored = await storedSettings();
  await new IndexedDBKVStore().put(SETTINGS_KEY, {
    ...stored,
    inboxes: { ...(stored.inboxes ?? {}), [vtaDid]: record },
  });
}

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: config.ts setInbox (lines as provided): 'export async function setInbox(vtaDid: string, record: InboxRecord): Promise {\n const stored = await storedSettings();\n await new IndexedDBKVStore().put(SETTINGS_KEY, {\n ...stored,\n inboxes: { ...(stored.inboxes ?? {}), [vtaDid]: record },\n });\n}' — this is a non-atomic get-then-put with no transaction or lock. EVIDENCE NOT FOUND: No mutex, versioning, or IndexedDB transaction spanning both get and put calls anywhere i
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

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

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.

🔵 Unsafe Formatstring (3 occurrences)

Field Detail
Severity LOW
Location packages/extension/src/offscreen.ts:635
Finding ID github_pr-821b049de23b
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 finding references packages/extension/src/offscreen.ts line 635 but the evidence code_snippet field is empty and offscreen.ts is not present in the provided source_files. EVIDENCE NOT FOUND: No actual code excerpt from offscreen.ts is available to verify string concatenation into console.log/util.format with attacker-controlled input. CHANGED VS PRE-EXISTING: offscreen.ts is not in the provided source_files list, and its status as changed/unchanged by this MR cannot be determ
  • 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.

🟡 Legacy mediator carry-over adopts unattributed relay values without validation

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts
Finding ID github_pr-4ce138342330
CWE CWE-20
OWASP A03:2021-Injection
Detection Source threat_model

📝 Description:

The one-time migration of the legacy mediatorDid setting trusts the stored value's shape (a string) but performs no validation that it is actually a well-formed DID or a mediator the wallet should trust before persisting it into the new per-agent inbox map.

🌱 Root Cause: settings.mediatorDid is read from IndexedDB and written directly into setInbox without format validation beyond the loose validInboxes filter, which only checks typeof rec.did === "string".

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

if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) {
  await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" });
  console.info("[pnm inbound] carried the pinned relay over to", activeVtaDid);
}
// Clear the legacy keys either way, so this runs once.
await clearLegacyInbox();

🎯 Attack Scenario:

If an attacker can write to the extension's IndexedDB (e.g., via a separate extension vulnerability or a supply-chain compromise of a dependency with storage access), they could plant an arbitrary mediator DID with source operator, which the migration would carry over verbatim and which would then be pinned and never re-validated against the agent's real DID document.

Also flagged at this location (same code, other weakness framings): CWE-778 — Unattributed mediator DID from legacy settings silently dropped without integrity check on the discarded source

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 55%
  • AI Validation Evidence: EVIDENCE FOUND: background.ts snippet (not directly in provided source_files but referenced) shows the legacy migration: 'if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) { await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" }); }' which trusts settings.mediatorDid as a string without DID-format validation. EVIDENCE NOT FOUND: background.ts full file is not in provided source_files, so I cannot verify whether upstream getSettings() (
  • 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.

🟡 Unauthenticated DID-document resolution result trusted and auto-adopted as new inbox relay

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts:1493
Finding ID github_pr-8bc51bd8f500
CWE CWE-345, CWE-300, CWE-295
OWASP A08:2021 - Software and Data Integrity Failures
MITRE ATT&CK T1557 - Adversary-in-the-Middle
CAPEC CAPEC-142, CAPEC-94
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: The background worker's relay-follow logic accepts whatever mediatorDid a DID document resolution returns and immediately repoints the wallet's inbound listener to it, without verifying the resolution response's authenticity, exposing the wallet to relay-redirection via DID resolution spoofing.

📝 Description:

Enables silent hijacking of an agent's inbound DIDComm channel by an attacker capable of influencing DID resolution, allowing interception or forgery of authorization and credential-offer messages destined for the wallet holder.

🧪 Proof of Concept:

resp.result.mediatorDid from handleRefreshVtaTransports (ultimately backed by DID document resolution) is trusted purely on the basis of type/presence, with no cryptographic proof verification, before being persisted as the new trusted relay for the agent.

for (const vtaDid of vtaDids) {
  const held = inboxFor(settings, vtaDid);
  if (held?.source === "operator") continue; // pinned, deliberately

  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 relay for", vtaDid, e);
    continue;
  }

  if (!live) { continue; }
  if (live === held?.did) continue;

  await setInbox(vtaDid, { did: live, source: "agent" });
  moved = true;
  console.info("[pnm inbound]", vtaDid, "moved its relay:", held?.did ?? "(none)", "→", live);
}

Vulnerable lines: 1470, 1500

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

if (live === held?.did) continue;
await setInbox(vtaDid, { did: live, source: "agent" });
moved = true;
console.info("[pnm inbound]", vtaDid, "moved its relay:", held?.did ?? "(none)", "→", live);

💥 Impact:

Enables silent hijacking of an agent's inbound DIDComm channel by an attacker capable of influencing DID resolution, allowing interception or forgery of authorization and credential-offer messages destined for the wallet holder.

Confidentiality: High — inbound DIDComm messages for the agent (authorization requests, credential offers) can be intercepted by an attacker-controlled relay reached via spoofed DID resolution · Integrity: High — attacker relay can craft or modify inbound push requests before delivery · Availability: Low — legitimate mediator becomes unreachable once the wallet repoints

🧭 Reachability:

  • Network exposure: public
  • Auth barrier: none
  • Attack path: EP-002 (browser startup/update) → followAgentInbox() → handleRefreshVtaTransports (DID resolution) → live mediatorDid trusted → setInbox(vtaDid, {did: live, source:'agent'}) → startInboundListener() reopens against new relay

⚖️ Triage Factors:

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

Attack scenario: A network-positioned attacker who can influence DID document resolution (compromised resolver, DNS hijack, or unauthenticated transport) causes the wallet to silently adopt an attacker relay as an agent's new inbox during the periodic follow-agent-relay check.

🔧 Remediation:

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

Verify the DID document's cryptographic proof before trusting any service-endpoint change, and require explicit operator confirmation when the mediator's authority/domain changes entirely, rather than silently auto-following any resolution result.

Vulnerable code:

if (live === held?.did) continue;
await setInbox(vtaDid, { did: live, source: "agent" });

Secure code:

if (live === held?.did) continue;
const verified = await verifyDidDocumentProof(vtaDid, live); // cryptographic proof check
if (!verified) {
  console.warn("[pnm inbound] rejected unverified mediator change for", vtaDid);
  continue;
}
// Require operator confirmation on first-seen authority/domain change.
if (isNewAuthority(held?.did, live) && !(await confirmWithOperator(vtaDid, live))) {
  continue;
}
await setInbox(vtaDid, { did: live, source: "agent" });

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: background.ts (not in provided source_files, but cited by evidence at lines 1493-1500) shows if (live === held?.did) continue; await setInbox(vtaDid, { did: live, source: "agent" }); with only an equality check against the previously held DID, no signature/DID-document proof verification visible. EVIDENCE NOT FOUND: The actual handleRefreshVtaTransports implementation and any DID document verification logic are not present in source_files, so I cannot confirm whether upstream D
  • 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 #150

Field Value
Repository OpenVTC/vta-browser-plugin
Branch fix/per-agent-inboxesmain
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

Refactors a single wallet-wide DIDComm inbox/mediator setting into a per-agent inbox map (WalletSettings.inboxes: Record<vtaDid, InboxRecord>), fixing a silent message-loss bug where only one onboarded agent's mediator could ever be tracked. Adds one-time migration logic to carry forward legacy operator-pinned relays and extends the boot-time adoption and periodic relay-follow logic to operate per-agent instead of only for the active VTA.

Diff: +245 / -118 lines
Types: feature, security, refactor

📁 File Classifications

packages/extension/src/active-vta.ts

  • Type: security

packages/extension/src/background.ts

  • Type: security

packages/extension/src/bridge-protocol.ts

  • Type: config

packages/extension/src/config.ts

  • Type: security

🛡️ STRIDE Threat Model

Identified Threats (11)

🟠 STRIDE-1: Unvalidated chrome.storage.local Deserialization in parseAgentMediatorDids

Field Detail
Category Tampering, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 7.5 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-20,CWE-345
CAPEC CAPEC-176,CAPEC-693
OWASP A08:2021 - Software and Data Integrity Failures

Description: chrome.storage.local read in readAgentMediatorDids allows JSON injection/tampering of pnm-connection/v3 due to lack of schema validation on parsed mediatorDid values, resulting in the wallet adopting an attacker-controlled DIDComm relay as its inbox

Evidence: packages/extension/src/active-vta.ts:1-40

export function parseAgentMediatorDids(raw: unknown): Record<string, string> {
  if (typeof raw !== "string") return {};
  try {
    const parsed = JSON.parse(raw) as {...};
    const out: Record<string, string> = {};
    for (const [vtaDid, entry] of Object.entries(parsed.state?.connections?.vtas ?

Attack Scenario:

  1. An attacker achieves write access to chrome.storage.local for this extension's origin — e.g. via a separate compromised extension with storage permission overlap, a malicious content script exploiting a different vulnerability, or a supply-chain compromised dependency running in the extension context.
  2. The attacker writes a crafted 'pnm-connection/v3' JSON blob with a state.connections.vtas[<vtaDid>].mediatorDid field pointing to an attacker-controlled DIDComm mediator URL/DID.
  3. On next background worker boot, readAgentMediatorDids() in active-vta.ts reads and JSON.parses this value with only a try/catch for parse failure, not schema/type strictness beyond typeof entry?.mediatorDid === 'string'.
  4. parseAgentMediatorDids returns the attacker's mediator DID keyed by the legitimate vtaDid string.
  5. adoptMissingInboxes in background.ts calls inboxToAdopt(inboxFor(current, vtaDid) ?? {}, advertised[vtaDid]); if the wallet has no existing inbox for that VTA (common on fresh installs or post-migration), the attacker's mediator DID is adopted via setInbox(vtaDid, { did: adopt, source: 'agent' }).
  6. All subsequent DIDComm inbound messages intended for this wallet are now routed through the attacker-controlled relay, enabling message interception, replay, or wallet compromise via malicious step-up/execution requests.

Preconditions: Attacker has some means of writing to chrome.storage.local for the extension (compromised co-located extension, malicious native messaging host, or corrupted upgrade artifact), Target VTA DID has no existing operator-pinned inbox entry (source !== 'operator')

Existing Controls: typeof checks on mediatorDid before including it in the parsed output • try/catch around JSON.parse to prevent crash on malformed data • inboxToAdopt() declines to overwrite existing operator-pinned entries

Recommended Mitigations: Validate that mediatorDid values conform to an expected DID syntax (e.g. did:peer:2 or a DIDComm-compliant scheme) before treating them as adoptable • Cryptographically verify the agent's advertised mediator against a signed DID document rather than trusting cached chrome.storage.local state • Add integrity checks (e.g. HMAC or signature) on the pnm-connection/v3 blob written by the trusted onboarding flow • Restrict chrome.storage.local write access via manifest permissions review and Content-Security-Policy hardening


🟡 STRIDE-2: Silent Message Loss via Missing Inbox Adoption Warning Only Logged to Console

Field Detail
Category Denial of Service, Repudiation
Severity Medium
Likelihood Likely
CVSS 5.3 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 Medium
CWE CWE-778,CWE-390
CAPEC CAPEC-125
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: Multi-agent inbox adoption in adoptMissingInboxes allows silent denial of DIDComm message delivery due to console-only warning when no inbox and no advertised mediator exist for an agent, resulting in unnoticed loss of availability for that agent's inbound channel

Evidence: packages/extension/src/background.ts:505-525

} else if (!inboxFor(current, vtaDid) && !advertised[vtaDid]) {
  console.warn(
    "[pnm inbound]",
    vtaDid,
    "has no inbox relay and advertises none to adopt — it cannot reach this wallet.",
  );
}

Attack Scenario:

  1. A wallet onboards a second agent whose mediator advertisement fails to propagate to the persisted connection (e.g. transient network failure during onboarding, or the agent's DID document temporarily lacks a service endpoint).
  2. adoptMissingInboxes in background.ts iterates vtaDids and finds neither inboxFor(current, vtaDid) nor advertised[vtaDid] for this agent.
  3. The code logs console.warn(...) stating the agent has no inbox and cannot be reached, but returns no user-facing error, throws no exception, and does not surface state via any UI badge referenced in the provided files.
  4. The operator continues to believe the wallet is functioning normally (no other agent's flows are disrupted), while all messages intended for this specific agent (step-up requests, credential offers) are permanently dropped at the mediator layer with zero record on the wallet side.
  5. Over time this becomes indistinguishable from a targeted DoS: an adversary who can suppress a single agent's mediator advertisement (e.g. by DNS-poisoning or MITM at onboarding time) can silently sever that agent's inbound channel without triggering any wallet-visible alert.
  6. Because the warning is console.warn only, it is invisible to a typical operator inspecting the extension's popup UI, and no telemetry pipeline captures it (no error reporting call observed in the reduced source).

Preconditions: Multi-agent wallet, Onboarding or DID document resolution transiently fails to yield a mediator for at least one agent, No UI surface currently reflects per-agent inbox health to the operator (based on available source)

Existing Controls: Explicit console.warn logging when no inbox exists and nothing can be adopted • followAgentInbox() periodically attempts to re-resolve via handleRefreshVtaTransports

Recommended Mitigations: Surface per-agent inbox health directly in the extension UI (setup-pane.tsx / network-pane.tsx) rather than console-only logging • Emit a structured, persisted diagnostic event queryable by the operator or a self-test routine • Add automatic periodic retry with exponential backoff for agents lacking any inbox binding • Alert via badge/notification API when an onboarded agent has zero working inbox after N boot cycles


🟡 STRIDE-3: TOCTOU Race in adoptMissingInboxes Between Settings Read and setInbox Write

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

Description: Read-modify-write sequence in adoptMissingInboxes and setInbox allows a lost-update race condition due to non-atomic get/put against IndexedDBKVStore, resulting in silently dropped inbox writes for concurrently updated agents

Evidence: packages/extension/src/config.ts:~60-75

export async function setInbox(vtaDid: string, record: InboxRecord): Promise<void> {
  const stored = await storedSettings();
  await new IndexedDBKVStore().put(SETTINGS_KEY, {
    ...stored,
    inboxes: { ...(stored.inboxes ?? {}), [vtaDid]: record },
  });
}

Attack Scenario:

  1. adoptMissingInboxes calls getSettings() once at the top to snapshot settings and advertised, then loops over vtaDids calling setInbox(vtaDid, {...}) for each agent individually.
  2. setInbox in config.ts performs its own storedSettings() read immediately before its put, but if followAgentInbox() (triggered by a browser startup/update event) runs concurrently in the same or a different service-worker invocation, both functions can interleave: Worker A reads stored settings, Worker B reads stored settings, Worker A writes inboxes with its patch, Worker B writes inboxes with its own (stale) patch, overwriting Worker A's update for a different vtaDid entry that Worker B's read did not yet include.
  3. Because MV3 service workers can be woken by multiple independent triggers (chrome.runtime.onStartup, onInstalled, message events) in rapid succession, two calls to adoptMissingInboxes/followAgentInbox can genuinely race against the same IndexedDB record.
  4. The result is a silently dropped inboxes[vtaDid] entry for whichever agent's update was clobbered, reintroducing exactly the 'another agent's pushes going quietly nowhere' failure mode the map was designed to prevent.
  5. No locking, versioning (e.g. optimistic concurrency token), or transactional guarantee is present in IndexedDBKVStore.get/.put as used here.

Preconditions: Multiple concurrent service worker wake events (startup + agent transport refresh + inbound listener boot) in close temporal proximity, Wallet has 2+ onboarded agents so overlapping per-agent writes are possible

Existing Controls: setInbox reads current stored state immediately before writing, narrowing (but not eliminating) the race window • Comments in code explicitly document the shallow-merge hazard this design tries to avoid

Recommended Mitigations: Use IndexedDB's native transactional guarantees (readwrite transaction spanning get+put) rather than two separate KV store calls • Serialize all settings writes through a single in-memory mutex/queue within the service worker • Add optimistic concurrency versioning to WalletSettings and reject/retry writes on version mismatch


🟡 STRIDE-4: Legacy mediatorDid Carry-Over Grants Operator-Pinned Relay to Wrong Active Agent

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

Description: One-time migration logic in adoptMissingInboxes allows misattribution of an operator's pinned relay due to reliance on readActiveVtaDid() at migration time rather than the agent the operator actually configured it for, resulting in a different agent's traffic being silently routed through an operator-chosen relay never intended for it

Evidence: packages/extension/src/background.ts:485-497

if (settings.mediatorDidSource === "operator" && settings.mediatorDid && activeVtaDid) {
  await setInbox(activeVtaDid, { did: settings.mediatorDid, source: "operator" });
  console.info("[pnm inbound] carried the pinned relay over to", activeVtaDid);
}

Attack Scenario:

  1. An operator running a legacy single-agent (or single-active-VTA) wallet manually pins a custom relay via Setup → Message routing, which sets the deprecated wallet-wide settings.mediatorDid / mediatorDidSource='operator'.
  2. Before the wallet restarts (and before the migration runs), the operator onboards a SECOND agent and switches the active VTA pointer to that new agent, e.g. via normal UI flow.
  3. On the next boot, adoptMissingInboxes executes the one-time carry-over: const activeVtaDid = await readActiveVtaDid(); reads the CURRENT active VTA (the second, newly-onboarded agent) rather than the agent the operator was actually configuring when they pinned the relay.
  4. Since settings.mediatorDidSource === 'operator' and activeVtaDid is now the second agent, setInbox(activeVtaDid, { did: settings.mediatorDid, source: 'operator' }) binds the FIRST agent's manually-pinned custom relay to the SECOND (unrelated) agent.
  5. This mis-migration marks the entry as source: 'operator', which per followAgentInbox's logic ('pinned, deliberately') will NEVER be auto-corrected even if the second agent later advertises its own legitimate mediator — permanently misrouting that agent's inbound DIDComm traffic through a relay the operator never intended for it.
  6. If the operator's custom relay is less trustworthy than the agent's own advertised mediator (e.g. a self-hosted test relay), this silently downgrades the confidentiality/integrity of the second agent's inbound channel with no correction path short of manual intervention.

Preconditions: Legacy single wallet-wide mediatorDid+operator source is set, Active VTA changes between the pin action and the next background worker boot, Wallet has 2+ agents at migration time

Existing Controls: Migration only runs once (clearLegacyInbox clears legacy keys immediately after), limiting exposure window • Only applies when mediatorDidSource is exactly 'operator'

Recommended Mitigations: Persist the vtaDid the operator was actively viewing at the moment they pinned the legacy relay, rather than inferring it later from current active VTA state • Prompt the operator to confirm relay-to-agent binding explicitly during the one-time migration UI flow • Log a clearly visible (non-console-only) migration notice showing exactly which agent inherited the pinned relay, allowing manual correction


🟠 STRIDE-5: Unauthenticated DID Document Fetch in followAgentInbox Trusts Network Response

Field Detail
Category Spoofing, Tampering
Severity High
Likelihood Possible
CVSS 7.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-345,CWE-300
CAPEC CAPEC-142,CAPEC-94
OWASP A08:2021 - Software and Data Integrity Failures

Description: followAgentInbox's call to handleRefreshVtaTransports allows a network-positioned attacker to inject a spoofed mediator DID via DID document resolution due to lack of verified provenance on the resolved response before it is adopted as the new inbox, resulting in wallet inbound traffic being silently redirected to an attacker-controlled relay

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

if (live === held?.did) continue;
await setInbox(vtaDid, { did: live, source: "agent" });
moved = true;
console.info("[pnm inbound]", vtaDid, "moved its relay:", held?.did ?? "(none)", "→", live);

Attack Scenario:

  1. followAgentInbox() in background.ts periodically calls handleRefreshVtaTransports({ type: RUNTIME_REFRESH_VTA_TRANSPORTS, vtaDid }) to re-resolve each non-operator-pinned agent's DID document and extract its currently advertised mediatorDid.
  2. An attacker positioned on the network path to the DID resolution service (e.g. a malicious/compromised DID resolver, a DNS hijack of the resolver endpoint, or a MITM against an unencrypted resolution transport) returns a DID document containing an attacker-controlled mediatorDid.
  3. The code path treats any string returned in resp.result.mediatorDid as authoritative live relay data: if (live === held?.did) continue; await setInbox(vtaDid, { did: live, source: "agent" }); — no additional signature verification of the DID document, no pinning/TOFU comparison against a previously trusted mediator beyond simple equality check, and no anomaly detection for an abrupt relay change.
  4. The wallet immediately adopts the new mediatorDid and calls startInboundListener(), re-establishing its inbound DIDComm session against the attacker's relay.
  5. All future messages intended for that agent (step-up authorization requests, credential offers, executor pushes) are now delivered to the attacker's relay, who can inspect, drop, or tamper with them before/instead of forwarding to the legitimate wallet — enabling message interception or spoofed request injection against the holder.
  6. Because the change is logged only via console.info ('agent moved its relay'), a legitimate-looking log line masks what is functionally a session-hijacking redirect.

Preconditions: Attacker can influence DID resolution response (compromised resolver, DNS/MITM on resolution path, or malicious mediator advertising itself opportunistically), Agent's inbox entry is not operator-pinned (source === 'agent')

Existing Controls: Equality check against the currently held mediatorDid avoids redundant no-op writes • Errors during resolution are caught and treated conservatively (relay left unchanged) • Operator-pinned entries are explicitly excluded from this auto-follow behavior

Recommended Mitigations: Verify DID document signatures/proofs cryptographically before trusting any embedded service endpoint change • Implement anomaly detection / operator confirmation prompt when an agent's advertised mediator changes to an entirely new domain/authority • Pin DID resolution transport to TLS with certificate/DoH validation and consider DNSSEC-aware resolvers • Rate-limit and audit-log mediator changes with a user-visible notification, not console-only


🔵 STRIDE-6: Missing Non-Repudiation for Inbox Migration and Adoption Decisions

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:L/SA:N
Residual Severity Low
CWE CWE-778
CAPEC CAPEC-268
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: Console-only logging in adoptMissingInboxes and followAgentInbox allows inbox routing changes to occur without durable audit trail due to reliance on ephemeral console.info/warn statements, resulting in an operator or forensic investigator being unable to reconstruct who/what changed a wallet's inbox binding after the fact

Evidence: packages/extension/src/background.ts:multiple

console.info("[pnm inbound] inbox adopted from agent:", vtaDid, "→", adopt);

Attack Scenario:

  1. Every inbox adoption, migration carry-over, and agent-follow relay change is logged exclusively via console.info/console.warn in background.ts.
  2. Chrome extension service worker console output is ephemeral: it is only visible while DevTools is attached to that specific worker instance, and is lost on worker termination/respawn (which MV3 does frequently and unpredictably).
  3. If an attacker (via one of the storage-tampering vectors in STRIDE-1 or the DID-spoofing vector in STRIDE-5) successfully redirects an agent's inbox to a malicious relay, no persistent, queryable audit record survives to help the operator or an incident responder determine when the change happened, what the prior mediatorDid was, or whether it was attacker-driven versus a legitimate agent relay migration.
  4. This absence of durable evidence means the responsible party (attacker or misconfigured legitimate flow) can effectively deny the change occurred, and root-causing an inbox compromise after the fact becomes largely reliant on incidental logs that may no longer exist.

Preconditions: An inbox-redirection event has occurred (via any vector) and needs to be investigated after the fact, No external logging/telemetry pipeline captures console output (based on available source)

Existing Controls: Every code path affecting inbox state does emit a console log line at time of change • Log lines include the old and new value where relevant, aiding real-time debugging

Recommended Mitigations: Persist a structured, append-only audit log of inbox changes (old value, new value, source, timestamp) in IndexedDB alongside WalletSettings • Surface a visible change history to the operator via the extension UI • Forward critical inbox-change events to a remote telemetry/SIEM pipeline where operationally applicable


🟡 STRIDE-7: Type Confusion via Malformed InboxRecord Bypasses validInboxes Filter Under Prototype Pollution

Field Detail
Category Tampering, Information Disclosure
Severity Medium
Likelihood Unlikely
CVSS 5.4 CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-20,CWE-1321
CAPEC CAPEC-153
OWASP A03:2021 - Injection

Description: validInboxes deserialization in config.ts allows an attacker-influenced IndexedDB record to partially bypass integrity filtering due to shallow per-key type checking without deep object validation or prototype sanitization, resulting in unexpected property injection into the trusted inboxes map

Evidence: packages/extension/src/config.ts:~30-40

function validInboxes(raw: Record<string, unknown>): Record<string, InboxRecord> {
  const out: Record<string, InboxRecord> = {};
  for (const [vtaDid, value] of Object.entries(raw)) {
    const rec = value as Partial<InboxRecord> | null;
    if (!rec || typeof rec.did !== "string" || !rec.did) cont

Attack Scenario:

  1. If any upstream code path (not shown in the reduced source, but plausible given IndexedDBKVStore is a generic get/put abstraction) allows attacker-influenced data to reach the raw object passed into validInboxes — e.g. via a compromised dependency or a bug in a JSON merge elsewhere in the extension.
  2. validInboxes iterates Object.entries(raw) and validates only that rec.did is a non-empty string and rec.source is exactly 'agent' or 'operator', copying matching entries into a freshly constructed object — this by itself is reasonably defensive against simple prototype pollution since it builds a new object and only copies validated primitive fields.
  3. However, no length/format constraints are placed on rec.did beyond non-empty string — an attacker able to write to the underlying record could supply an extremely long string, a string containing control characters, or a string that collides with a legitimate DID format check elsewhere (e.g. downstream DIDComm session code that trusts this value to construct a URL or WebSocket endpoint), potentially enabling downstream injection if any consumer of InboxRecord.did does not itself re-validate DID syntax before use in a URL context.
  4. Because the reduced source does not show the consumer that turns InboxRecord.did into an actual mediator connection URL, this remains a theoretical gap rather than a confirmed exploit chain in-scope, but the missing DID-format validation at the point of storage is a defense-in-depth gap worth flagging.

Preconditions: A DID-format string without further syntax validation reaches a URL-construction or session-establishment sink elsewhere in the codebase (not visible in reduced source), Attacker-influenced data reaches the raw settings object before validInboxes runs

Existing Controls: validInboxes constructs a fresh output object rather than mutating raw, limiting prototype pollution risk • Type-narrowing checks (typeof rec.did === 'string', enum check on source) filter out gross type confusion

Recommended Mitigations: Add explicit DID syntax validation (regex or DID-core parser) in validInboxes before accepting a did value • Bound the accepted string length to a sane maximum • Re-validate DID syntax again at the point of actual mediator connection/URL construction (defense in depth)


🔵 STRIDE-8: Unbounded Iteration Over vtaDids Enables Worker-Local Resource Exhaustion

Field Detail
Category Denial of Service
Severity Low
Likelihood Unlikely
CVSS 4.0 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 Low
CWE CWE-400,CWE-770
CAPEC CAPEC-125
OWASP A04:2021 - Insecure Design

Description: adoptMissingInboxes and followAgentInbox's per-vtaDid loop allows service worker resource exhaustion due to unbounded fan-out of network calls (handleRefreshVtaTransports) per onboarded agent with no concurrency cap, resulting in degraded availability of the inbound listener bootstrap on wallets with many onboarded agents

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

for (const vtaDid of vtaDids) {
  const held = inboxFor(settings, vtaDid);
  if (held?.source === "operator") continue;
  let live: string | undefined;
  try {
    const resp = await handleRefreshVtaTransports({ type: RUNTIME_REFRESH_VTA_TRANSPORTS, vtaDid });
    ...
  } catch (e) { ... continue; }

Attack Scenario:

  1. followAgentInbox() iterates await readAllVtaDids() and issues one handleRefreshVtaTransports call (itself performing a DID document network fetch) per non-operator-pinned agent, sequentially with no explicit concurrency limit or timeout budget across the whole sweep.
  2. If an operator (or a malicious onboarding flow, chained with a separate vulnerability that allows mass-onboarding of many agent DIDs) causes the wallet to track a very large number of VTAs, each boot/startup/update event triggers a correspondingly large serial chain of network round-trips.
  3. Because each handleRefreshVtaTransports call can independently be slow or hang on an unresponsive mediator resolver, and there is no per-call timeout enforcement visible in the reduced source, a handful of unresponsive agents can stall the entire sweep, delaying startInboundListener() reconciliation for all other agents as well.
  4. This degrades the wallet's overall inbound message availability during the stall window and, combined with MV3's aggressive service worker recycling, could cause repeated retriggering of the same slow sweep on every worker respawn, compounding the delay.

Preconditions: Wallet has been onboarded to an unusually large number of agents, At least one agent's mediator/DID resolver is slow or unresponsive

Existing Controls: try/catch per-agent prevents one failed resolution from throwing and aborting the whole sweep • Continue-on-error logic (continue) allows the loop to proceed past an unresponsive agent

Recommended Mitigations: Add a per-agent timeout to handleRefreshVtaTransports calls within followAgentInbox • Run per-agent resolution concurrently with a bounded concurrency pool instead of strictly sequentially • Cap the maximum number of onboarded agents supported per wallet, or paginate the sweep across multiple worker wake cycles


🟡 STRIDE-9: Missing Authentication on OFFSCREEN_START_INBOUND Message Enables Cross-Context Spoofing

Field Detail
Category Spoofing, Tampering
Severity Medium
Likelihood Possible
CVSS 6.4 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-346,CWE-862
CAPEC CAPEC-148
OWASP A01:2021 - Broken Access Control

Description: OFFSCREEN_START_INBOUND message handling between background.ts and offscreen.ts allows a spoofed vtaDids list to be injected due to lack of sender-origin verification on the runtime message, resulting in the offscreen document reconciling inbound sessions for attacker-chosen VTA identifiers

Evidence: packages/extension/src/bridge-protocol.ts:1584-1596

export interface OffscreenStartInboundRequest {
  target: typeof OFFSCREEN_TARGET;
  type: typeof OFFSCREEN_START_INBOUND;
  vtaDids: string[];
}

Attack Scenario:

  1. The background worker sends an OFFSCREEN_START_INBOUND message to the offscreen document listing which vtaDids should have active inbound listener sessions.
  2. Per recon EP-004, this is a MESSAGE entry point with auth_required=false; if any other extension component, or a compromised content script with runtime messaging access, can construct and dispatch a message matching this type/target shape, the offscreen document's message listener (not shown in reduced source, but implied by the offscreen.ts file's role) may reconcile its inbound sessions based on the attacker's supplied vtaDids list rather than the legitimate one computed by readAllVtaDids().
  3. This could cause the offscreen document to close a legitimate agent's inbound session (denial of listening) or open an inbound session for a vtaDid the operator never actually onboarded, particularly if the offscreen document trusts the message body without cross-checking against its own independent read of onboarded VTA state.
  4. Combined with STRIDE-1 (storage tampering) or a compromised sibling extension, an attacker could desynchronize which agents the wallet is actually listening for versus which the operator believes are active.

Preconditions: Attacker can send extension-internal runtime messages matching OFFSCREEN_START_INBOUND's shape (e.g. via a separate compromised extension with cross-extension messaging, or a bug allowing content-script-to-background message forgery), Offscreen document does not independently re-verify the vtaDids list against its own trusted state read

Existing Controls: Offscreen documents are not directly reachable from web content per Chrome's MV3 isolation model, reducing the practical attack surface to same-extension or privileged contexts • Message type constants are used for routing rather than free-form strings, providing some structure

Recommended Mitigations: Have the offscreen document independently verify the vtaDids list against its own read of chrome.storage.local / IndexedDB rather than trusting the message payload verbatim • Add a sender.id check in the offscreen document's runtime.onMessage listener to ensure messages originate only from this extension's own background context • Sign or nonce-protect internal extension messages carrying security-relevant state changes


🔵 STRIDE-10: Deprecated mediatorDid Fields Left Readable Post-Migration Enable Downgrade Replay

Field Detail
Category Tampering
Severity Low
Likelihood Unlikely
CVSS 3.7 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-1104,CWE-670
CAPEC CAPEC-268
OWASP A08:2021 - Software and Data Integrity Failures

Description: Deprecated mediatorDid/mediatorDidSource fields in WalletSettings allow a downgrade-style reintroduction of the legacy single-relay model due to the fields remaining part of the type/schema and clearable-but-not-unwritable, resulting in a future code path or bug that writes to the deprecated fields silently reinstating the pre-per-agent vulnerability class

Evidence: packages/extension/src/config.ts:~48-52

/** @deprecated Superseded by {@link inboxes}. Read once by the boot
 *  migration in `background.ts` and then cleared. Never write it. */
mediatorDid?: string;
/** @deprecated Superseded by {@link mediatorDid}. See {@link mediatorDid}. */
mediatorDidSource?: InboxSource;

Attack Scenario:

  1. WalletSettings.mediatorDid and mediatorDidSource remain defined in the interface (marked @deprecated) purely to be read once by the boot migration and then cleared via clearLegacyInbox().
  2. Nothing in the type system or runtime prevents some other code path (a future feature, a regression, or a malicious dependency update) from calling setSettings({ mediatorDid: ... }) again, since setSettings still accepts a Partial<WalletSettings> that includes these fields per the merge/write helper shown in the diff.
  3. If such a write occurs after migration has already run once, the legacy fields would sit inert (never re-read since adoptMissingInboxes gates its one-time carry-over on settings.mediatorDid || settings.mediatorDidSource truthiness) but could resurrect the one-time carry-over logic on a subsequent update if clearLegacyInbox is not called again, since the carry-over check only looks at whether the fields are currently set, not whether migration has already completed via some separate flag.
  4. This creates a subtle state where a stray write to a deprecated field could cause adoptMissingInboxes to re-run its one-time-migration branch on a later boot, re-triggering the carry-over-to-active-VTA logic described in STRIDE-4 against a now-different active VTA, silently re-misattributing an operator-pinned relay a second time.

Preconditions: Some future or third-party code path writes to the deprecated mediatorDid/mediatorDidSource fields after initial migration, No explicit 'migration already completed' flag exists separate from the presence/absence of the deprecated fields themselves

Existing Controls: clearLegacyInbox() deletes both deprecated keys immediately after the one-time carry-over runs • Code comments explicitly mark the fields @deprecated and instruct 'Never write it'

Recommended Mitigations: Add a dedicated boolean migration-completed flag independent of the deprecated fields' presence, so re-appearance of those fields cannot re-trigger carry-over logic • Remove the deprecated fields from the WalletSettings type entirely once a sufficient rollout window has passed, converting setSettings to reject unknown legacy keys • Add a runtime assertion/lint rule flagging any write to mediatorDid/mediatorDidSource outside the migration module


🔵 STRIDE-11: forgetInbox Enables Operator-Triggered Loss of Agent Reachability Without Confirmation Safeguard

Field Detail
Category Denial of Service
Severity Low
Likelihood Unlikely
CVSS 2.5 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N
Residual Severity None
CWE CWE-840
CAPEC CAPEC-580
OWASP A04:2021 - Insecure Design

Description: forgetInbox function in config.ts allows accidental or malicious removal of a live agent's inbox binding due to lack of any confirmation, undo, or reachability-impact warning at the call site, resulting in an inadvertent denial of service for that agent's inbound channel

Evidence: packages/extension/src/config.ts:~85-90

export async function forgetInbox(vtaDid: string): Promise<void> {
  const stored = await storedSettings();
  const inboxes = { ...(stored.inboxes ?? {}) };
  delete inboxes[vtaDid];
  await new IndexedDBKVStore().put(SETTINGS_KEY, { ...stored, inboxes });
}

Attack Scenario:

  1. forgetInbox(vtaDid) unconditionally deletes the inbox entry for the given vtaDid: delete inboxes[vtaDid]; with no check for whether that agent is currently the sole reachable channel for a wallet, nor any soft-delete/undo mechanism.
  2. If this function is invoked from a UI action (setup-pane.tsx, not fully shown in reduced source) in response to a misclick, or if a compromised/malicious extension message triggers this via an insufficiently authenticated internal message path (see STRIDE-9's pattern applied to a hypothetical FORGET_INBOX message), the agent immediately loses its only route for inbound DIDComm messages.
  3. Because there is no automatic re-adoption path once an inbox is explicitly forgotten (adoptMissingInboxes only backfills when nothing was ever set, and forgetting sets it to explicitly absent which is indistinguishable from never-set on the next boot — meaning the NEXT boot's adoptMissingInboxes would actually re-adopt from advertised[vtaDid] if available, partially mitigating this), the impact is time-bounded to until the next worker boot, but any inbound push in that window is lost.

Preconditions: forgetInbox is reachable via a UI action or internal message without a confirmation step, An inbound push occurs in the window between forgetting and the next boot's re-adoption

Existing Controls: adoptMissingInboxes will naturally re-adopt an advertised mediator on the next boot since a forgotten entry is indistinguishable from unset • Function is explicitly documented as an operator-triggered action ('used when the operator forgets that agent')

Recommended Mitigations: Add a confirmation dialog before invoking forgetInbox from the UI • Log a persisted, operator-visible record of the forget action and its timestamp • Consider a grace-period soft-delete rather than immediate hard removal



🍝 PASTA Threat Model

Application Purpose

A browser extension wallet implementing a decentralized identity (DIDComm/VTA) holder that must reliably receive inbound messages from multiple onboarded agents via per-agent mediator relays, enabling verifiable credential exchange and step-up authentication for relying parties.

Inherent Risks

  • Browser extension architecture exposes wallet state to chrome.storage.local, a storage medium shared across the extension's own contexts and potentially reachable by other privileged code.
  • DIDComm's store-and-forward mediator model inherently trusts a third-party relay to hold messages, creating a confidentiality/availability dependency outside the wallet's direct control.
  • MV3 service worker lifecycle causes frequent unpredictable re-execution of boot/migration logic, increasing the surface for race conditions.
  • Migration from a single wallet-wide relay to a per-agent model introduces a transitional period where legacy and new data models coexist, a classic source of state-confusion bugs.

Objectives

Risk: Accept low-likelihood/low-impact console-log-only visibility gaps where a full UI overhaul is out of scope for this migration.; Treat any agent with zero configured or adoptable inbox as a documented, visible degraded state rather than a silent failure.
Business: Maintain reliable message delivery for every onboarded agent to preserve trust with relying parties and executors.; Avoid forcing operators to re-onboard (and re-grant RP ACLs) when migrating internal data models.
Security: Prevent unauthorized or spoofed mediator DIDs from being adopted as an agent's trusted inbox.; Ensure inbox routing changes are auditable and attributable.
Financial: Minimize support costs associated with silent message-loss incidents that are hard to diagnose.
Compliance: Preserve non-repudiation expectations implicit in DIDComm trust models where message routing changes should be attributable.
Functional: Support multiple simultaneously onboarded agents, each with an independent inbox/relay binding.; Automatically follow an agent's relay when it legitimately moves, without disturbing operator-pinned overrides.
Operational: Ensure the migration from legacy single-relay settings to the per-agent map runs safely exactly once per wallet.; Keep the inbound listener reconciliation lightweight enough to run on every MV3 service worker spin-up.

Business Impact Analysis (3)

BIA-1: Inbound DIDComm Message Delivery (Critical)

The end-to-end process by which an executor or relying party pushes a message to an onboarded agent's mediator, which the wallet's offscreen document listens to and delivers into the holder's session.

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

  • Stakeholders: Executors / Operators / Relying Parties / Wallet Holders
  • Dependencies: Chrome MV3 Background Service Worker / DIDComm Mediator Relay (External) / IndexedDBKVStore Settings Store / Offscreen Document Inbound Listener / chrome.storage.local Connection Cache
  • Disruptions: Attacker-controlled mediator DID adopted as an agent's inbox (STRIDE-1, STRIDE-5) / Silent loss of inbox binding for an agent with no advertised mediator (STRIDE-2) / Race condition dropping a concurrently written inbox entry (STRIDE-3) / Operator's pinned relay misattributed to the wrong agent during migration (STRIDE-4)
  • Impacts: Complete inability for a relying party/executor to reach a specific onboarded agent, blocking credential issuance or step-up authentication flows / Interception or tampering of sensitive DIDComm messages by an attacker-controlled relay / Erosion of operator trust if routing failures are undiagnosable due to console-only logging / Potential RP ACL invalidation if the affected operator resorts to full re-onboarding as a workaround

BIA-2: Legacy-to-Per-Agent Settings Migration (High)

The one-time boot-time process that carries over a deprecated wallet-wide mediatorDid setting into the new per-agent inbox map and clears the legacy keys.

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

  • Stakeholders: Operators / Wallet Maintainers
  • Dependencies: IndexedDBKVStore Settings Store / Active VTA Pointer (chrome.storage.local)
  • Disruptions: Migration runs against a stale or incorrect active VTA pointer, misattributing an operator-pinned relay (STRIDE-4) / A future code regression re-writes deprecated fields, causing the one-time migration to re-trigger unexpectedly (STRIDE-10)
  • Impacts: A previously correctly-pinned custom relay silently applied to the wrong agent, degrading confidentiality/integrity of that agent's channel / Operator confusion requiring manual support intervention to re-diagnose relay bindings

BIA-3: Agent Relay Change Following (Medium)

The periodic process (on browser startup/update) that re-resolves each non-pinned agent's DID document to detect and follow a legitimate relay move.

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

  • Stakeholders: Operators / Executors
  • Dependencies: DID Document Resolver (External Network) / IndexedDBKVStore Settings Store / handleRefreshVtaTransports Message Handler
  • Disruptions: Spoofed or MITM'd DID document resolution causing adoption of an attacker relay (STRIDE-5) / Sequential per-agent network calls stalling the whole sweep for wallets with many agents (STRIDE-8)
  • Impacts: Wallet inbound channel silently hijacked to an attacker relay for one or more agents / Delayed inbound listener startup impacting overall message availability during the stall window

Technical Scope

Roles (3): RO-1 Operator · RO-2 Onboarded Agent · RO-3 External Attacker

Actors (3): AC-1 Wallet Operator · AC-2 Background Service Worker · AC-3 DIDComm Mediator Relay

Entry Points (7): EP-001 Background Worker Boot / startInboundListener · EP-002 Browser Startup/Update Trigger / followAgentInbox · EP-003 RUNTIME_REFRESH_VTA_TRANSPORTS Message Handler · EP-004 OFFSCREEN_START_INBOUND Message · EP-005 pnm-connection/v3 Storage Read · EP-006 WalletSettings IndexedDB Read/Write · EP-007 External Mediator Relay Push/Auth

Threat Actors (3): TA-1 Network-Positioned Attacker · TA-2 Malicious Co-Located Extension · TA-3 Careless or Confused Operator

Infrastructure (2): IF-1 Browser Client Runtime · IF-2 Third-Party/Self-Hosted Mediator Relay

Trust Boundaries (3): TB-1 Browser Extension Privileged Context · TB-2 External DIDComm Mediator Network · TB-3 Content Script / Web Page Boundary

External Entities (2): EE-1 DIDComm Mediator Relay Operator · EE-2 Onboarded Agent / DID Document Publisher

System Components (7): SC-1 Background Service Worker · SC-2 Config/Settings Store Module · SC-3 Active VTA / Connection Cache Parser · SC-4 Offscreen Inbound Listener Document · SC-5 IndexedDB Settings Data Store · SC-6 chrome.storage.local Connection Cache · SC-7 External DIDComm Mediator Relay

Resources And Assets (4): RA-1 Per-Agent Inbox Map (WalletSettings.inboxes) · RA-2 Legacy mediatorDid/mediatorDidSource Fields · RA-3 pnm-connection/v3 Cache · RA-4 Holder Identity / DID Key Material

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

Use Cases (2)

  • Multi-Agent Inbox Adoption on Wallet Boot: On every background service worker startup, the wallet backfills a missing per-agent inbox by reading its own persisted settings and each onboarded agent's advertised mediator from the connection cach
  • Agent Relay Follow on Browser Startup: On browser startup/update, the wallet re-resolves each non-pinned agent's DID document to detect a legitimate mediator relay move and updates that agent's inbox entry accordingly, then reopens the inb

📋 Risk Registry (5)

ID Title Severity Residual Priority Effort
RISK-001 Attacker-controlled mediator adoption via unvalidated storage/DID-document trust High High Immediate High
RISK-002 Silent per-agent inbox failure invisible to operator Medium Medium Short-Term Medium
RISK-003 Concurrent settings writes causing lost inbox updates Medium Medium Short-Term Medium
RISK-004 Misattributed operator-pinned relay during legacy migration Medium Low Medium-Term Low
RISK-005 Unauthenticated internal messaging enabling inbound session desynchronization Medium Low Medium-Term Medium

⚔️ Attack Scenarios (4)

SC-1: Background Service Worker

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Network-Positioned Attacker<br><i>Intercept/redirect DIDComm traffic</i>" }
    TA2@{ shape: rect, label: "TA-2: Malicious Co-Located Extension<br><i>Tamper with shared storage</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-5: DID Doc Spoof in followAgentInbox<br><i>High / Possible</i>" }
    S2@{ shape: rect, label: "STRIDE-1: Storage Deserialization Tampering<br><i>High / Likely</i>" }
    S3@{ shape: rect, label: "STRIDE-4: Legacy Carry-Over Misattribution<br><i>Medium / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    C1@{ shape: rect, label: "CAPEC-142: DNS Cache Poisoning" }
    C2@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
    C3@{ shape: rect, label: "CAPEC-693: StarJacking" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    W1@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
    W2@{ shape: rect, label: "CWE-20: Improper Input Validation" }
    W3@{ shape: rect, label: "CWE-668: Exposure of Resource to Wrong Sphere" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC@{ shape: rect, label: "SC-1: Background Service Worker" }
  end
  TA1 --> S1
  TA2 --> S2
  TA1 --> S3
  S1 --> C1
  S2 --> C3
  S3 --> C2
  C1 --> W1
  C3 --> W2
  C2 --> W3
  W1 --> SC
  W2 --> SC
  W3 --> SC
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FF0000,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
Loading

SC-2: Config/Settings Store Module

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Malicious Co-Located Extension<br><i>Tamper with shared storage</i>" }
    TA3@{ shape: rect, label: "TA-3: Careless or Confused Operator<br><i>Unintentional misconfiguration</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-3: TOCTOU Race in Inbox Write<br><i>Medium / Possible</i>" }
    S2@{ shape: rect, label: "STRIDE-11: forgetInbox Without Confirmation<br><i>Low / Unlikely</i>" }
    S3@{ shape: rect, label: "STRIDE-10: Deprecated Field Downgrade Replay<br><i>Low / Unlikely</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    C1@{ shape: rect, label: "CAPEC-25: Forced Deadlock/Race Condition" }
    C2@{ shape: rect, label: "CAPEC-580: System Footprinting via Legitimate Function" }
    C3@{ shape: rect, label: "CAPEC-268: Audit Log Manipulation" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    W1@{ shape: rect, label: "CWE-362: Concurrent Execution using Shared Resource" }
    W2@{ shape: rect, label: "CWE-840: Business Logic Errors" }
    W3@{ shape: rect, label: "CWE-1104: Use of Unmaintained Third Party Components" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC@{ shape: rect, label: "SC-2: Config/Settings Store Module" }
  end
  TA2 --> S1
  TA3 --> S2
  TA3 --> S3
  S1 --> C1
  S2 --> C2
  S3 --> C3
  C1 --> W1
  C2 --> W2
  C3 --> W3
  W1 --> SC
  W2 --> SC
  W3 --> SC
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#00FF00,stroke-width:2px
  linkStyle 2 stroke:#00FF00,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#00FF00,stroke-width:2px
  linkStyle 5 stroke:#00FF00,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#00FF00,stroke-width:2px
  linkStyle 8 stroke:#00FF00,stroke-width:2px
  linkStyle 9 stroke:#FFA500,stroke-width:2px
  linkStyle 10 stroke:#00FF00,stroke-width:2px
  linkStyle 11 stroke:#00FF00,stroke-width:2px
Loading

SC-3: Active VTA / Connection Cache Parser

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Malicious Co-Located Extension<br><i>Tamper with shared storage</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-1: Unvalidated Storage Deserialization<br><i>High / Likely</i>" }
    S2@{ shape: rect, label: "STRIDE-7: Type Confusion Bypasses validInboxes<br><i>Medium / Unlikely</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    C1@{ shape: rect, label: "CAPEC-176: Configuration/Environment Manipulation" }
    C2@{ shape: rect, label: "CAPEC-153: Input Data Manipulation" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    W1@{ shape: rect, label: "CWE-20: Improper Input Validation" }
    W2@{ shape: rect, label: "CWE-1321: Prototype Pollution" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC@{ shape: rect, label: "SC-3: Active VTA / Connection Cache Parser" }
  end
  TA2 --> S1
  TA2 --> S2
  S1 --> C1
  S2 --> C2
  C1 --> W1
  C2 --> W2
  W1 --> SC
  W2 --> SC
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
Loading

SC-4: Offscreen Inbound Listener Document

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Malicious Co-Located Extension<br><i>Tamper with internal messaging</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-9: OFFSCREEN_START_INBOUND Spoofing<br><i>Medium / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    C1@{ shape: rect, label: "CAPEC-148: Content Spoofing" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    W1@{ shape: rect, label: "CWE-346: Origin Validation Error" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC@{ shape: rect, label: "SC-4: Offscreen Inbound Listener Document" }
  end
  TA2 --> S1
  S1 --> C1
  C1 --> W1
  W1 --> SC
  linkStyle 0 stroke:#FFA500,stroke-width:2px
  linkStyle 1 stroke:#FFA500,stroke-width:2px
  linkStyle 2 stroke:#FFA500,stroke-width:2px
  linkStyle 3 stroke:#FFA500,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 11

By Severity: Low: 4 · High: 2 · Medium: 5

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

🎯 Attack Surface

Kill Chain 1: An attacker who can write to chrome.storage.local (via a compromised co-located extension or a supply-chain-compromised dependency) crafts a malicious pnm-connection/v3 blob (STRIDE-1); on the next background worker boot, parseAgentMediatorDids trusts the string-typed mediatorDid field without DID-syntax or provenance validation, and adoptMissingInboxes adopts it into the per-agent inbox map for any VTA lacking an existing binding, silently redirecting that agent's entire inbound DIDComm channel to the attacker's relay. Kill Chain 2: Independent of storage tampering, a network-positioned attacker capable of MITM or DNS-hijacking the DID resolution path can intercept followAgentInbox's periodic handleRefreshVtaTransports calls (STRIDE-5), returning a spoofed DID document whose advertised mediator is adopted with only a simple equality check against the previously held value — no signature verification — resulting in the same relay-hijack outcome but via a purely network-side vector that requires no local code execution. Kill Chain 3: These two vectors can chain with the TOCTOU race in setInbox (STRIDE-3): if an attacker times a spoofed adoption to coincide with a legitimate followAgentInbox sweep for a different agent, the non-atomic read-modify-write against IndexedDBKVStore can cause the legitimate update to be lost while the attacker's fraudulent entry for another agent persists, compounding both an availability loss and a confidentiality breach in a single race window. Kill Chain 4: The legacy migration carry-over (STRIDE-4) and the unauthenticated OFFSCREEN_START_INBOUND message (STRIDE-9) represent a lower-likelihood but architecturally significant secondary surface: a careless timing of onboarding relative to a legacy relay pin can misattribute trust to the wrong agent, and if internal extension messaging can be forged by a co-located malicious extension, the offscreen document's session reconciliation could be desynchronized from the operator's actual intent, amplifying whichever primary redirect vector (Kill Chain 1 or 2) is already in play by ensuring the wallet's own listener state does not reflect reality even after a manual correction attempt.

🛡️ Risk Mitigation Strategy

Priority 1 (Immediate): Close the two High-severity trust gaps that allow attacker-controlled mediator adoption — add DID-syntax and provenance validation to parseAgentMediatorDids/validInboxes so that only well-formed, plausible DIDs are ever considered adoptable, and require cryptographic verification of DID document authenticity in followAgentInbox before treating a resolved mediator as authoritative; both changes directly cut off the two highest-CVSS kill chains (STRIDE-1, STRIDE-5) with a bounded, well-scoped code change to two already-isolated parsing functions. Priority 2 (Short-Term): Eliminate the TOCTOU race in the settings read-modify-write cycle by wrapping get+put in a genuine IndexedDB transaction or serializing all settings writes through a single in-worker queue, and simultaneously replace console-only logging for silent inbox failures and routing changes with a persisted, UI-visible audit trail — these two changes are architecturally related (both touch the same settings-write path) and together resolve STRIDE-2, STRIDE-3, and STRIDE-6 at once. Priority 3 (Medium-Term): Harden the internal extension messaging surface by having the offscreen document independently re-verify the vtaDids list it receives against its own trusted storage read rather than trusting the OFFSCREEN_START_INBOUND payload verbatim, and add an explicit migration-completed flag decoupled from the presence of deprecated legacy fields to prevent any future accidental re-triggering of the one-time carry-over logic — these are defense-in-depth measures against lower-likelihood but architecturally lingering risks (STRIDE-9, STRIDE-10, STRIDE-4). Priority 4 (Long-Term): Once the per-agent inbox model has been in production long enough to be confident no legacy wallets remain unmigrated, remove the deprecated mediatorDid/mediatorDidSource fields from the WalletSettings type entirely, eliminating the downgrade-replay surface (STRIDE-10) at its root rather than merely guarding against it,


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

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

Confirmed (3)

  • 🟡 Unauthenticated/unvalidated adoption of attacker-controlled mediatorDid from chrome.storage.local (triaged HIGH→MEDIUM)
  • 🟡 Legacy relay migration binds operator-pinned relay to wrong agent based on current active VTA
  • 🟡 Non-atomic read-modify-write on settings.inboxes enables lost-update race across concurrent worker invocations

Must-Review-By-Human (4)

  • 🔵 Unsafe Formatstring (3 occurrences)
  • 🔵 Unattributed mediator DID from legacy settings silently dropped without integrity check on the discarded source
  • 🟡 Unauthenticated DID-document resolution result trusted and auto-adopted as new inbox relay (triaged HIGH→MEDIUM)
  • 🟡 Legacy mediator carry-over adopts unattributed relay values without validation

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