Skip to content

feat(console): click through a persona to what it actually presents - #170

Merged
stormer78 merged 1 commit into
mainfrom
feat/persona-linkage-views
Sep 7, 2026
Merged

feat(console): click through a persona to what it actually presents#170
stormer78 merged 1 commit into
mainfrom
feat/persona-linkage-views

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Two requests, both "let me see the linkage", from opposite ends. Both are answered by a click rather than a column — see the last section for why that is not laziness.

On a binding row: what does this persona actually present?

The Presents cell showed Glenn - Developer · 3 claim(s) and stopped there. It is now a link.

persona/binding/list returns a profile name and a count, never contents — thin by construction at the agent, because a binding read that returned values would make the disclosure gate decorative. So the count says how much there is, and clicking asks what it is: binding/get resolves the profileId the listing withholds, then profile/get?resolve=true resolves what it projects.

This is a truthful answer only because a pool edit now pushes. Until OpenVTC/verifiable-trust-infrastructure#1281 nothing called rematerialise, so the resolved profile and the materialised copy a verifier receives could disagree — and this view would have shown a holder values their verifiers were never given. Reading the profile is the right source because the push exists, not a convenient stand-in for the copy. That is written into the component, because someone will otherwise wonder why it does not read the binding directly.

On a profile row: who presents this, and where?

A new "Who presents it" expander scans every context.

No single task answers this. persona/profile/delete computes it agent-side and computes it in order to refuse; binding/list is per context and returns a name, not an id. So the console assembles it, and the assembly has a correctness property worth stating:

Filtering candidates by profile name drops no true match. A persona presenting profile P necessarily reports P's name, because the name in the listing comes from the bound profile itself. The name is therefore a sound pre-filter — it can admit a wrong profile that happens to share a name, and cannot exclude a right one. binding/get then confirms each candidate by profileId, removing exactly the false positives the name admits.

The direction of that imprecision matters more than the cost:

  • a false positive shows a linkage that is not there — visible, and removed by the confirmation step;
  • a false negative renders as "no persona presents this profile", which is a holder concluding no linkage exists when nobody actually looked.

So a context the agent refuses is named as a gap rather than skipped, and "found nothing" stays distinguishable from "could not ask". When more than one persona presents the same profile the view says what that means: they disclose identical values, so anyone who sees two of them knows they are the same person, permanently.

Cost is one binding/list per context plus one binding/get per name match — proportional to the answer, not to the store.

Why the scan is not in the component

It lives in packages/extension/src/manager/profile-bindings.ts with its readers injected — the same shape every network helper here uses for testability, and why the module has no relative imports. The soundness claim above is the thing worth a test, and a component's reasoning is not testable. Same move profile-entries.ts and buildConsentView made before it.

Eight tests, mutation-checked:

  • trusting the name without confirming by id → "two profiles sharing a name do not become one linkage" fails
  • swallowing a refused context → "a context that refuses is named, not silently dropped" fails

Every negative assertion is paired with a positive one, because a scan that finds nothing anywhere satisfies all of the former.

A click, not a column

Both views are behind a button. Even bounded, the profile scan fans out across every context, and the answer is the holder's linkage map — the artifact this family exists to keep from being assembled casually. A column would build it on every page load, for every row, and leave it on screen whether or not anyone asked.

Verification

npm run lint, npm run build, npm test — 796 across four workspaces (up 8). All six Assert* steps in ci.yml run locally against a real build.

Built on main after the trust-tasks 0.17.1 bump (#167); persona.tsx was untouched by it.

Still not exercised against a live agent beyond the panels already reported on.

Two views, both answering "where does this reach", from opposite ends.

**On a binding row**, the profile name and claim count are now a link
rather than an answer. `binding/list` returns a name and a count and
never contents — thin by construction at the agent, because a binding
read that returned values would make the disclosure gate decorative —
so the count says how much there is, and clicking asks what it is:
`binding/get` for the profile id, then `profile/get?resolve=true`.

That is a truthful answer only because a pool edit now pushes. Until
VTI#1281 nothing called `rematerialise`, so the resolved profile and the
copy a verifier receives could disagree, and this view would have shown
a holder values their verifiers were never given. Reading the profile is
right *because* the push exists, not as a stand-in for the copy.

**On a profile row**, "Who presents it" scans every context. No single
task answers it — `profile/delete` computes it agent-side and only in
order to refuse — so the console assembles it, and the assembly has a
property worth stating: filtering candidates by profile *name* drops no
true match, because the name in a listing comes from the bound profile
itself. It can admit a wrong profile that shares a name; `binding/get`
then confirms by id and removes exactly those.

The direction of that imprecision is the design. A false positive is
visible and gets removed. A false negative renders as "no persona
presents this profile" — a holder concluding no linkage exists when
nobody looked. So a context that refuses is named as a gap rather than
skipped, and "found nothing" stays distinguishable from "could not ask".

The scan lives in `profile-bindings.ts` with its readers injected, out
of the component, because that soundness claim is the thing worth a
test. Eight of them, mutation-checked: trusting the name without
confirming fails one, swallowing a refused context fails another.

A click and not a column, in both cases. The answer is the holder's
linkage map — the artifact this family exists to keep from being
assembled casually — and a column would build it on every page load,
for every row, whether or not anyone asked.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 2f6923b into main Sep 7, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/persona-linkage-views branch September 7, 2026 09:52
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

4 findings need a human to review/validate.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #170

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/persona-linkage-viewsmain
Validated 2026-09-07
Scan ID ebf971ea
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 1 · with findings: 1 · files: 3 · findings: 6

Module Files scanned Findings
packages/extension 3 6

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 0 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

⚠️ 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.

🟡 Cross-context persona/profile linkage aggregation exposes full de-anonymization map on demand

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/profile-bindings.ts:79
Finding ID github_pr-7be60d561064
CWE CWE-200, CWE-359
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1590 - Gather Victim Identity Information
CAPEC CAPEC-118, CAPEC-169
DREAD 7.2
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — Scanner confirms reachability (attack path EP-002 → scanForProfile, reachable=true) and high exploitability (deterministic, no special tooling, no rate limiting). Business impact is high because the linkage map is the core asset the product protects. However, exploit maturity is only 'conceptual' (no weaponized/public exploit — this is business-logic misuse, not a generic injectable CWE class), auth barrier is basic (requires a holder session, not fully unauthenticated), and environment/production status is unconfirmed. These gaps keep it at high rather than critical: CVSS-equivalent impact is high but not RCE/critical-tier, and full critical criteria (confirmed production + strong exploit evidence) are not met.
  • Composite score: 6.3
  • Environment: production

Summary: scanForProfile aggregates binding data across all stored contexts to answer 'who presents this profile', and the resulting linkage map — explicitly described in the UI as permanently identifying — is obtainable via a single click by anyone with holder-level session access, without additional confirmation, rate limiting, or audit logging.

📝 Description:

Any party who can reach this UI action while passing holderGate (a legitimate holder, or an attacker who has hijacked/borrowed such a session) can, in one click, obtain the complete set of personas and contexts that are provably the same real-world individual — a permanent, non-revocable disclosure per the application's own warning text.

🧪 Proof of Concept:

This function performs an unthrottled, unconfirmed fan-out across every known context to build the complete cross-context linkage map for a profile, on a single UI action, with no additional authorization step beyond the initial holderGate check that already gated the button click.

export async function scanForProfile(
  contextIds: readonly string[],
  profile: { profileId: string; name: string },
  readers: { list: ..., get: ... },
): Promise<ScanResult> {
  const rows: PresentedBy[] = [];
  const unreadable: string[] = [];
  for (const contextId of contextIds) {
    let candidates: BindingListRow[];
    try {
      const listed = await readers.list(contextId);
      candidates = bindingCandidates(listed.personas, profile.name);
    } catch { unreadable.push(contextId); continue; }
    for (const candidate of candidates) {
      try {
        const exact = await readers.get(contextId, candidate.personaDid);
        if (exact.profileId === profile.profileId) {
          rows.push({ contextId, personaDid: candidate.personaDid, claimCount: exact.claimCount ?? 0 });
        }
      } catch { unreadable.push(`${contextId}/${candidate.personaDid}`); }
    }
  }
  return { rows, unreadable };
}

Vulnerable lines: 74, 116

🔎 Evidence: packages/extension/src/manager/profile-bindings.ts:79

for (const contextId of contextIds) {
  const listed = await readers.list(contextId);
  candidates = bindingCandidates(listed.personas, profile.name);
  ...
  if (exact.profileId === profile.profileId) rows.push({ contextId, personaDid, claimCount });

💥 Impact:

Any party who can reach this UI action while passing holderGate (a legitimate holder, or an attacker who has hijacked/borrowed such a session) can, in one click, obtain the complete set of personas and contexts that are provably the same real-world individual — a permanent, non-revocable disclosure per the application's own warning text.

Confidentiality: High — full cross-context identity correlation map for a given profile is disclosed in one interaction. · Integrity: None. · Availability: None.

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: EP-002 (Who presents it button) → toggle(profileId,'where') → ProfileBindings component → scanForProfile() at profile-bindings.ts:79 → listBindings/getBinding via managerSender

⚖️ Triage Factors:

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

Attack scenario: A holder-authorized user (or an attacker who has hijacked a holder session) clicks 'Who presents it' on a profile to get a one-click complete map of every persona/context pair that shares that profile, permanently correlating those identities.

🔧 Remediation:

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

Introduce an explicit, separately-confirmed step (e.g. a confirmation dialog stating what is about to be assembled and why) before executing the fan-out, plus audit logging of each invocation, so the action is deliberate and traceable rather than a single incidental click. This does not change the underlying feature (which is intentional and useful to holders) but adds friction and accountability proportional to the sensitivity of the artifact produced.

Vulnerable code:

export async function scanForProfile(contextIds, profile, readers) {
  for (const contextId of contextIds) {
    const listed = await readers.list(contextId);
    const candidates = bindingCandidates(listed.personas, profile.name);
    for (const candidate of candidates) {
      const exact = await readers.get(contextId, candidate.personaDid);
      if (exact.profileId === profile.profileId) rows.push({...});
    }
  }
}

Secure code:

export async function scanForProfile(contextIds, profile, readers, opts) {
  // Require an explicit, freshly-issued step-up confirmation token before
  // performing the fan-out, and audit-log the invocation.
  if (!opts?.confirmedByUser) {
    throw new Error("Explicit confirmation required before assembling cross-context linkage map");
  }
  await opts.audit?.log("linkage-scan", { profileId: profile.profileId, at: Date.now() });
  // ...existing scan logic unchanged...
}

Additional recommendations:

  • Rate-limit / throttle repeated scanForProfile invocations per session.
  • Require step-up auth (e.g. re-entry of a passphrase/biometric) before the scan for high-sensitivity profiles.
  • Log/alert on high-frequency scanForProfile calls that might indicate automated harvesting rather than a holder manually checking their own linkage exposure.
  • Isolate the manager pane's JS context from other extension content scripts via strict CSP / sandboxing to reduce the chance of ATK-003-style handler invocation from an untrusted script.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: profile-bindings.ts is not included in source_files, but the description quotes scanForProfile fanning out readers.list/readers.get across contexts and pushing rows when exact.profileId === profile.profileId; persona.tsx (provided) confirms the caller side wires list: (contextId) => listBindings(managerSender, {...}) and get: (contextId, personaDid) => getBinding(managerSender, {...}) inside ProfileBindings. This corroborates the aggregation exists and is reachable from the UI. EVIDENCE NOT FOUND: The full profile-bindings.ts source is not present in source_files, so I cannot directly verify the exact confirm-by-profileId logic, whether any server-side/backend authorization gate exists on listBindings/getBinding RPCs, or whether the design deliberately restricts this to holder-only access via holderGate (which is client-side, per finding 3). CHANGED VS PRE-EXISTING: profile-bindings.ts and the ProfileBindings component in persona.tsx are new/changed by this PR (new file + new function scanForProfile referenced in persona.tsx), so this is CHANGED code in scope. VERDICT JUSTIFICATION: The behavior described (aggregating cross-context linkage) appears intentional per the code comments in persona.tsx and design docs referenced, and is presented as a deliberate feature with a UI warning acknowledging the exact risk — this looks like an accepted design tradeoff rather than an accidental vulnerability, but since the actual profile-bindings.ts source isn't available to confirm authorization boundaries or that this is holder-only, a human should review whether the intentional feature has appropriate access control at the RPC layer.
  • 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.

🟡 Client-side-only authorization gate (holderGate) on profile/binding disclosure actions

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/panes/persona.tsx:1204
Finding ID github_pr-69d2075a45ba
CWE CWE-602, CWE-863, CWE-285
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1548 - Abuse Elevation Control Mechanism
CAPEC CAPEC-115, CAPEC-180
DREAD 4.2
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • CWE-602 client-side-only enforcement is confirmed reachable in code, but exploitation requires an additional script-execution primitive in the manager page (not directly network-reachable), and the scanner explicitly notes backend enforcement status is unconfirmed — it 'could already be safe' via defense-in-depth at the RPC layer. Exploit maturity is conceptual with no PoC/CVE. These factors keep it at medium rather than escalating to high; the missing gate (Exploitability ≥5 requires stronger evidence than 'conceptual/low') also prevents a high classification under the actionability gates.
  • Composite score: 4.9
  • Environment: production

Summary: The manager UI gates profile-claim and binding-linkage disclosure buttons using only a client-side disabled prop derived from holderGate(authority); this is not a substitute for server-side authorization on the underlying RPC calls.

📝 Description:

If backend authorization is missing or incomplete, a session/party without holder authority could retrieve resolved profile claim values (personaProfileGet) or the full cross-context persona-to-profile linkage map (scanForProfile via listBindings/getBinding), defeating the app's core privacy compartmentalization guarantee.

🧪 Proof of Concept:

denied only affects the disabled DOM attribute; onClick still references the live toggle closure regardless of denied's value, so any invocation path other than a genuine click-on-disabled-button (which browsers block, but scripted invocation does not) reaches the RPC-triggering code.

const denied = holderGate(authority);
...
<Button
  kind="quiet"
  disabled={Boolean(denied)}
  {...(denied ? { title: denied } : {})}
  onClick={() => toggle(p.profileId, "claims")}
>
  {open?.profileId === p.profileId && open.view === "claims" ? "Hide" : "What it presents"}
</Button>
<Button
  kind="quiet"
  disabled={Boolean(denied)}
  {...(denied ? { title: denied } : {})}
  onClick={() => toggle(p.profileId, "where")}
>

Vulnerable lines: 1204, 1216

🔎 Evidence: packages/extension/src/manager/panes/persona.tsx:1204

const denied = holderGate(authority);
...
<Button kind="quiet" disabled={Boolean(denied)} {...(denied ? { title: denied } : {})}
  onClick={() => toggle(p.profileId, "claims")}>

💥 Impact:

If backend authorization is missing or incomplete, a session/party without holder authority could retrieve resolved profile claim values (personaProfileGet) or the full cross-context persona-to-profile linkage map (scanForProfile via listBindings/getBinding), defeating the app's core privacy compartmentalization guarantee.

Confidentiality: Medium — potential unauthorized disclosure of profile claim values and cross-context persona linkage map if the backend RPC layer does not independently re-check authority. · Integrity: None observed. · Availability: None observed.

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: EP-001/EP-002 (manager pane UI buttons) → toggle(profileId, view) → ResolvedProfile/ProfileBindings → personaProfileGet/listBindings/getBinding(managerSender) at persona.tsx ~1263-1272

⚖️ Triage Factors:

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

Attack scenario: An attacker with script execution in the manager page (or with holderGate wrongly evaluating denied) could invoke toggle handlers directly, bypassing the UI-only disabled state, to trigger profile/binding RPCs.

🔧 Remediation:

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

Add a defense-in-depth client-side guard (do nothing on click when denied) AND, critically, verify/implement authorization re-validation inside the managerSender background handlers for persona/profile/get, persona/binding/list, and persona/binding/get so the RPC layer itself refuses requests from a denied authority, independent of what the UI does.

Vulnerable code:

const denied = holderGate(authority);
<Button disabled={Boolean(denied)} onClick={() => toggle(p.profileId, "claims")}>

Secure code:

// Client-side (UX only, keep for accessibility):
const denied = holderGate(authority);
<Button disabled={Boolean(denied)} onClick={() => { if (!denied) toggle(p.profileId, "claims"); }}>

// Server-side (mandatory, in the managerSender/background RPC handler):
async function handlePersonaProfileGet(request, ctx) {
  const denied = holderGate(ctx.authority);
  if (denied) throw new AuthorizationError(denied);
  return personaProfileGetImpl(request);
}

Additional recommendations:

  • Add integration tests that call managerSender RPC handlers directly with a denied authority and assert rejection.
  • Never treat a disabled DOM attribute as a security control; document this in the repo's security guidelines.
  • Add server-side audit logging of denied-authority attempts to reach these RPCs.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 30%
  • AI Validation Evidence: EVIDENCE FOUND: persona.tsx (provided) does not show the exact lines cited (1204-1212), but the file does show a similar pattern for other buttons — none of the provided persona.tsx excerpt shows the ProfilesPanel/holderGate code directly (the file provided is PersonaPane, not the ProfilesPanel with the 'What it presents'/'Who presents it' buttons). The finding's own quoted snippet disabled={Boolean(denied)} {...(denied ? { title: denied } : {})} onClick={() => toggle(p.profileId, "claims")} shows only a client-side disabled prop gating the onClick — this is consistent with a UI-only gate. EVIDENCE NOT FOUND: The actual ProfilesPanel component, the holderGate function definition, and — critically — the backend/managerSender RPC handler code for personaProfileGet/listBindings/getBinding are not present in source_files, so I cannot confirm or deny whether server-side authorization mirrors holderGate. CHANGED VS PRE-EXISTING: persona.tsx is in the changed-files list for this MR (PersonaPane content shown is part of persona.tsx), and the ProfilesPanel/holderGate buttons are new UI wiring for the profile-bindings feature, so this is CHANGED code in scope. VERDICT JUSTIFICATION: This is a legitimate defense-in-depth concern (disabled prop is not a security control), but without visibility into the RPC-layer authorization implementation I cannot confirm exploitability (i.e., that bypassing the disabled attribute actually reaches an unauthorized RPC) — a human must verify server-side enforcement in managerSender/background handlers.
  • 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.

🟡 Silent per-context failure downgrades to secondary UI warning, risking false 'no linkage' conclusion

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/profile-bindings.ts:86
Finding ID github_pr-8542433499ee
CWE CWE-754, CWE-393
OWASP A04:2021 - Insecure Design, A09:2021 - Security Logging and Monitoring Failures
MITRE ATT&CK T1499 - Endpoint Denial of Service (indirect, for suppression scenario)
CAPEC CAPEC-227, CAPEC-131
Reachability 🔴 Reachable
Exploit Maturity theoretical
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • The vulnerability is a logic/UX flaw (CWE-754/393) with reachable but precondition-heavy exploitability (attacker must induce or exploit backend failures for a specific context — not demonstrated as directly attacker-triggerable). No CVSS, no exploit maturity beyond 'theoretical', environment unknown (not confirmed production), and impact is degraded trust in a self-audit tool rather than data compromise, integrity of user credentials, or availability of the extension itself. This does not meet high-severity gates (exploitability and public exploit are both low/none), so medium is accurate and consistent with the scanner's own assessment.
  • Composite score: 5.5
  • Environment: production

Summary: Per-context/persona read failures during the cross-context profile-binding scan are caught and recorded as 'unreadable' but the UI's primary empty-state message ('No persona presents this profile') is rendered identically whether the scan was fully successful or partially failed, with the incompleteness warning relegated to a secondary Note.

🧪 Proof of Concept:

The function correctly tracks unreadable contexts but returns a rows array that is structurally identical (empty) whether the scan succeeded with no matches or failed partway through; downstream rendering must inspect unreadable separately, and if it treats the empty rows as the primary signal, the distinction can be lost in the UI hierarchy.

for (const contextId of contextIds) {
  let candidates: BindingListRow[];
  try {
    const listed = await readers.list(contextId);
    candidates = bindingCandidates(listed.personas, profile.name);
  } catch {
    unreadable.push(contextId);
    continue;
  }
  for (const candidate of candidates) {
    try {
      const exact = await readers.get(contextId, candidate.personaDid);
      if (exact.profileId === profile.profileId) {
        rows.push({ contextId, personaDid: candidate.personaDid, claimCount: exact.claimCount ?? 0 });
      }
    } catch {
      unreadable.push(`${contextId}/${candidate.personaDid}`);
    }
  }
}
return { rows, unreadable };

Vulnerable lines: 79, 111

🔎 Evidence: packages/extension/src/manager/profile-bindings.ts:86

} catch {
  unreadable.push(contextId);
  continue;
}
...
} catch {
  unreadable.push(`${contextId}/${candidate.personaDid}`);
}

🧭 Reachability:

  • Network exposure: internal
  • Auth barrier: basic
  • Attack path: EP-002 → scanForProfile (profile-bindings.ts:79-111) → readers.list/get throwing → rows=[] with non-empty unreadable → ProfileBindings renders 'No persona presents this profile' + separate warning Note (persona.tsx ProfileBindings render block)

⚖️ Triage Factors:

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

Attack scenario: An attacker who can induce or exploit failures in a specific context's binding backend can cause that context to be silently excluded from the linkage scan, increasing the chance the holder misreads the secondary warning and concludes no linkage exists.

🔧 Remediation:

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

Distinguish 'confirmed empty' from 'incomplete/unknown' as separate states rather than both collapsing to the same rows.length === 0 empty-state render, and make the incompleteness warning visually equal to or more prominent than the empty-state message rather than a secondary Note.

Vulnerable code:

try {
  const listed = await readers.list(contextId);
  candidates = bindingCandidates(listed.personas, profile.name);
} catch {
  unreadable.push(contextId);
  continue;
}

Secure code:

try {
  const listed = await readers.list(contextId);
  candidates = bindingCandidates(listed.personas, profile.name);
} catch (err) {
  unreadable.push(contextId);
  // Optional: retry once with backoff before giving up, to reduce
  // transient-failure false negatives.
  continue;
}

// In the rendering layer, make completeness a precondition of the
// "no persona presents this profile" message:
const complete = unreadable.length === 0;
// Render: rows.length === 0 && complete ? "No persona presents this profile"
//         : rows.length === 0 && !complete ? "Scan incomplete — cannot confirm absence" (blocking/prominent)
//         : normal rows

Additional recommendations:

  • Add one retry-with-backoff before marking a context unreadable to reduce transient-failure false negatives.
  • Server-side: log/alert on repeated failures from a specific context's binding endpoint to detect adversarial suppression patterns.
  • Consider a tri-state result model (confirmed-absent / confirmed-present / unknown) instead of boolean emptiness.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 60%
  • AI Validation Evidence: EVIDENCE FOUND: profile-bindings.ts source is not directly provided, but the finding's own evidence snippet shows catch { unreadable.push(contextId); continue; } and a second catch pushing ${contextId}/${candidate.personaDid} to unreadable — consistent with a partial-failure design that surfaces failures in a list rather than halting. EVIDENCE NOT FOUND: The full UI rendering logic for how unreadable is surfaced (e.g., whether it's a prominent blocking warning vs a subtle Note) is not in the provided persona.tsx excerpt (the ProfileBindings component itself isn't shown, only PersonaPane). CHANGED VS PRE-EXISTING: profile-bindings.ts is new/changed by this PR (scanForProfile is a new export), so this is CHANGED code in scope. VERDICT JUSTIFICATION: The described behavior (catching errors into an 'unreadable' array rather than failing loudly) is a genuine design choice with real risk of false-negative interpretation, but since I cannot see the exact UI treatment of unreadable.length > 0 to judge whether it's adequately prominent, this requires human review to assess severity/adequacy of the existing warning.
  • 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.

🟡 Unbounded sequential network fan-out without timeout/error aggregation limits in scanForProfile

Field Detail
Severity MEDIUM
Location packages/extension/src/manager/profile-bindings.ts:95
Finding ID github_pr-ed97621d8df8
CWE CWE_754
OWASP A04:2021-Insecure Design
Detection Source threat_model

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • The flaw is a missing-timeout/unbounded-fan-out design issue (CWE-400/1088-like) affecting availability of a single local UI operation. It has no confidentiality/integrity impact, no confirmed exploit, and is only triggerable by the user's own action against agents their own personas interact with — this caps it well below high/critical. It remains a legitimate medium: real UX-blocking impact exists (code evidence confirms the sequential await pattern with no timeout), but there's no remote attacker-controlled exploitation path, no data exposure, and the scanner's own confidence is only 40%.
  • Composite score: 4.9
  • Environment: production

📝 Description:

The scan performs sequential awaited calls per context and per candidate persona with no timeout, retry limit, or cap on the number of contexts/candidates processed.

🌱 Root Cause: No bounds are placed on contextIds.length or candidates.length before initiating potentially many sequential remote calls triggered by a single UI click.

🔎 Evidence: packages/extension/src/manager/profile-bindings.ts:95

for (const contextId of contextIds) {
    let candidates: BindingListRow[];
    try {
      const listed = await readers.list(contextId);
      candidates = bindingCandidates(listed.personas, profile.name);
    } catch {
      unreadable.push(contextId);
      continue;
    }

    for (const candidate of candidates) {
      try {
        const exact = await readers.get(contextId, candidate.personaDid);

🎯 Attack Scenario:

A holder with many contexts/personas clicking 'Who presents it' triggers O(n*m) sequential awaited calls; a slow or unresponsive agent for one context could stall the entire scan without a timeout, degrading UX and potentially never resolving.

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 40%
  • AI Validation Evidence: EVIDENCE FOUND: The finding's evidence snippet shows a for (const contextId of contextIds) loop with sequential await readers.list(contextId) and nested await readers.get(contextId, candidate.personaDid) calls — consistent with unbounded sequential fan-out with no visible cap, timeout, or cancellation. EVIDENCE NOT FOUND: The full profile-bindings.ts file (including any possible caps like MAX_CONTEXTS or timeout wrappers) is not provided in source_files, so I cannot confirm the complete absence of any bounding logic elsewhere in the file, nor whether records.length (context count) is realistically bounded at the data-model level. CHANGED VS PRE-EXISTING: scanForProfile in profile-bindings.ts is new code added by this MR, so this is CHANGED code in scope. VERDICT JUSTIFICATION: Sequential (not parallel) fan-out is a real design choice that could cause latency/availability issues at scale, but without seeing the full file or knowing typical/max context counts in practice, I cannot confirm this rises to an exploitable DoS versus a acceptable-for-now tradeoff; a human should assess actual risk given expected data volumes.
  • 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 #170

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/persona-linkage-viewsmain
Generated 2026-09-07

ℹ️ 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

Adds a click-triggered 'who presents this profile' cross-context linkage lookup to the persona manager UI (ProfileBindings/scanForProfile), and refactors ResolvedProfile to accept primitive profileId/name so it can be reused by a new PersonaClaims component nested under the per-context bindings table. The feature intentionally assembles a sensitive cross-context identity linkage map on demand rather than automatically, with explicit UI warnings about permanent identity correlation and explicit surfacing of partial/failed reads.

Diff: +277 / -32 lines
Types: feature, refactor, test

🧩 Affected Components

Component Impact Change What Changed
ProfileBindings (cross-context linkage scan UI) critical new A brand-new, click-triggered UI feature and supporting algorithm that fan out listBindings/getBinding RPCs across every known context to
ResolvedProfile (profile claims resolver) medium modified Refactored from accepting a full PoolProfile object to accepting primitive profileId/name props, enabling reuse by the new PersonaClai
PersonaClaims (per-context, per-persona claims resolver) medium new New component performing a two-call resolution (getBinding then personaProfileGet resolve:true) to show what a specific persona presents in
ProfilesPanel state model low modified Single resolving string state replaced by unified open: {profileId, view} state and toggle() helper, plus new records prop threaded

📁 File Classifications

packages/extension/src/manager/panes/persona.tsx

  • Type: security

packages/extension/src/manager/profile-bindings.ts

  • Type: security

packages/extension/tests/manager-profile-bindings.test.mts

  • Type: test

🛡️ STRIDE Threat Model

Identified Threats (10)

🟠 STRIDE-1: Cross-Context Identity Linkage Map Construction via ProfileBindings Scan

Field Detail
Category Information Disclosure
Severity High
Likelihood Likely
CVSS 7.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:A/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity High
CWE CWE-200,CWE-359
CAPEC CAPEC-118,CAPEC-169
OWASP A01:2021 - Broken Access Control, A04:2021 - Insecure Design

Description: ProfileBindings component in COMP-002 allows aggregation of a holder's cross-context persona linkage map due to scanForProfile fanning out binding/list and binding/get calls across every stored context, resulting in disclosure of which personas correspond to the same real-world identity

Evidence: packages/extension/src/manager/profile-bindings.ts:1-122

export async function scanForProfile(contextIds, profile, readers) { for (const contextId of contextIds) { const listed = await readers.list(contextId); candidates = bindingCandidates(listed.personas, profile.name); for (const candidate of candidates) { const exact = await readers.get(contextId, can

Attack Scenario:

  1. An attacker with UI access to the manager panel (e.g. a compromised local session, shared device, or malicious browser extension with DOM access) opens the ProfilesPanel component in persona.tsx.
  2. Attacker clicks the 'Who presents it' button for a target profile, invoking toggle(profile.profileId, 'where'), which sets open state and renders ProfileBindings.
  3. ProfileBindings calls scanForProfile(records.map(r=>r.id), {profileId, name}, {list, get}) in profile-bindings.ts, iterating over every ContextRecord id known to the extension.
  4. For each contextId, listBindings(managerSender, {...parties, contextId}) is invoked, returning all bound personas and their profile names (BindingListRow[]).
  5. bindingCandidates() filters candidates whose profileName matches the target profile's name, then getBinding(managerSender, {contextId, personaDid}) confirms each candidate by profileId.
  6. The resulting rows array (contextId + personaDid + claimCount for every context) is rendered to the UI, along with the explicit warning: 'X personas present this profile. They disclose the same values, so anyone who sees two of them knows they are the same person — permanently.'
  7. Attacker now possesses a complete map correlating every persona/context pair sharing the same underlying profile, defeating the compartmentalization the persona system exists to provide.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Attacker has UI-level access to the extension's manager pane (physical access, session hijack, or another extension/script with DOM access to the manager page)., holderGate(authority) does not deny the requesting party (i.e., attacker holds or has hijacked a session with holder authority)., Multiple personas are bound to the same profile across two or more contexts.

Existing Controls: Click-triggered (not auto-loaded/columnar) aggregation limits casual/passive exposure. • holderGate authorization check disables the buttons when denied. • Explicit UI warning is surfaced when >1 persona presents the same profile, informing the holder of the linkage risk (transparency control, not a preventive control). • Thin binding/list response (name + count only, no profileId or contents) limits raw data exposure per call.

Recommended Mitigations: Add step-up authentication or explicit re-confirmation before running the cross-context scan. • Rate-limit or audit-log invocations of scanForProfile per session. • Consider requiring a secondary explicit consent dialog before executing a fan-out across all contexts (distinguishing from a single-context read). • Evaluate whether the manager pane should be isolated in its own security boundary from other browser extension content scripts (contentscript isolation, CSP).


🔵 STRIDE-2: Name-Collision False Positive Injection in bindingCandidates Pre-Filter

Field Detail
Category Tampering, Information Disclosure
Severity Low
Likelihood Possible
CVSS 3.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-697,CWE-843
CAPEC CAPEC-22
OWASP A04:2021 - Insecure Design

Description: bindingCandidates in profile-bindings.ts allows transient false-positive linkage display due to filtering solely by profileName string equality before confirmation, resulting in momentary incorrect linkage inference if profile names collide across distinct profiles

Evidence: packages/extension/src/manager/profile-bindings.ts:58-68

export function bindingCandidates(rows, profileName) { return rows.filter((r) => r.bound && r.profileName === profileName); }

Attack Scenario:

  1. A malicious or careless persona holder names two distinct profiles identically (e.g. both named 'work').
  2. scanForProfile calls bindingCandidates(rows, profile.name) which matches on r.profileName === profileName (profile-bindings.ts line ~63-68).
  3. The candidate is queued for a follow-up getBinding confirmation call, but between the list and get calls, or due to service-side eventual consistency, the confirmation could theoretically resolve to unexpected state.
  4. Although the code confirms by profileId before pushing to rows (mitigating the primary risk), any UI code path that surfaces candidates before confirmation (e.g. future debugging output, telemetry, or a modified UI variant) would show an incorrect linkage.
  5. This is primarily a design-robustness observation: the soundness argument depends on profileName uniqueness being irrelevant only because of the second get() confirmation step; any regression removing that confirmation reintroduces a false-positive-then-corrected disclosure.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Two or more profiles share an identical name field., A future code change removes or bypasses the get() confirmation step.

Existing Controls: get() call confirms candidate by exact profileId before inclusion in final rows (implemented correctly in current code). • Unit tests in manager-profile-bindings.test.mts pin the soundness property.

Recommended Mitigations: Add a regression test explicitly asserting that name-colliding profiles never appear in output unless profileId also matches (partially covered). • Enforce profile name uniqueness at creation/edit time to eliminate the ambiguity class entirely. • Add static/lint rule or code review gate to prevent any UI path from consuming candidates pre-confirmation.


🟡 STRIDE-3: Partial Failure Misrepresented as Absence in scanForProfile Unreadable Handling

Field Detail
Category Repudiation, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.1 CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-754,CWE-393
CAPEC CAPEC-227,CAPEC-131
OWASP A04:2021 - Insecure Design, A09:2021 - Security Logging and Monitoring Failures

Description: scanForProfile in profile-bindings.ts allows incomplete-result misinterpretation due to silent catch blocks converting context/persona read failures into an 'unreadable' list without halting or clearly forcing acknowledgment, resulting in a holder believing no linkage exists when the scan was actually incomplete

Evidence: packages/extension/src/manager/profile-bindings.ts:86-104

try { const listed = await readers.list(contextId); ... } catch { unreadable.push(contextId); continue; } ... try { const exact = await readers.get(contextId, candidate.personaDid); ... } catch { unreadable.push(`${contextId}/${candidate.personaDid}`); }

Attack Scenario:

  1. An attacker or misconfiguration causes one or more contexts (or the persona service backing them) to intermittently throw errors on listBindings or getBinding calls — e.g., by exhausting a rate limit, triggering a timeout, or exploiting a service-side bug.
  2. In profile-bindings.ts, the try/catch around readers.list() pushes the contextId to unreadable and continues (lines ~86-92), and similarly for readers.get() (lines ~96-103).
  3. The UI (ProfileBindings in persona.tsx) does render a warning Note when unreadable.length > 0, but this warning is visually secondary (a dismissible-style Note, not a blocking modal) compared to the primary 'no persona presents this profile' empty state.
  4. If an attacker can selectively induce failures for the one context/persona pair that would have proven a compromising linkage (e.g., via targeted DoS of a specific context's binding endpoint), the holder may overlook the muted warning and conclude — incorrectly — that no cross-context linkage exists.
  5. This grants the attacker a way to suppress detection of a linkage they wish to conceal (e.g. an attacker who controls one context and does not want it correlated to others), by ensuring that context's binding/list or binding/get consistently errors.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Attacker has ability to induce or exploit failures in listBindings/getBinding for a specific context (e.g., resource exhaustion, malformed context state, service outage)., Holder does not carefully read the secondary warning Note in the UI.

Existing Controls: unreadable array is explicitly surfaced in the UI rather than silently dropped. • Design documentation explicitly calls out the asymmetry risk (false negative vs false positive) as the property tests are written against. • Unit tests exist for the unreadable/partial-failure path per test file comments.

Recommended Mitigations: Elevate the 'incomplete answer' warning to a blocking/non-dismissible state when unreadable.length > 0, rather than a co-equal Note. • Add retry-with-backoff logic before marking a context unreadable to reduce transient-failure-induced false negatives. • Log/audit unreadable contexts server-side to detect patterns of selective failure suggesting adversarial suppression. • Consider distinguishing between 'confirmed empty' and 'incomplete/unknown' states more strongly in the returned data model (e.g., a tri-state rather than boolean rows.length===0).


🟡 STRIDE-4: UI-Only Authorization Bypass of holderGate in ProfilesPanel Toggle Actions

Field Detail
Category Elevation of Privilege, Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-602,CWE-863
CAPEC CAPEC-115,CAPEC-180
OWASP A01:2021 - Broken Access Control

Description: 'What it presents' and 'Who presents it' buttons in ProfilesPanel (COMP-002) allow bypass of the holderGate authorization check due to disabled={Boolean(denied)} being a purely client-side React prop rather than a server-enforced authorization gate, resulting in unauthorized invocation of personaProfileGet, listBindings, and getBinding RPCs if the disabled attribute is circumvented

Evidence: packages/extension/src/manager/panes/persona.tsx:~1090-1112

const denied = holderGate(authority); ... <Button kind="quiet" disabled={Boolean(denied)} {...(denied ? { title: denied } : {})} onClick={() => toggle(p.profileId, "claims")}>

Attack Scenario:

  1. Attacker gains the ability to execute arbitrary JS in the manager pane context (e.g., via a separate XSS in another pane, a malicious extension update, or dev-tools access to a shared device where holderGate denies the current authority).
  2. Attacker inspects persona.tsx and observes that denied = holderGate(authority) only sets the disabled prop and a title attribute on the Button components (lines near toggle handlers), a client-side rendering hint.
  3. Attacker directly invokes the underlying onClick handlers via the React fiber tree, browser devtools console, or a crafted event dispatch, bypassing the disabled attribute enforcement, calling toggle(profileId,'claims') or toggle(profileId,'where') directly.
  4. This triggers ResolvedProfile or ProfileBindings which call personaProfileGet(managerSender, {...parties, profileId, resolve:true}) or scanForProfile(...) respectively — RPC calls that reach the background/manager service (managerSender).
  5. If the managerSender RPC layer (COMP-003/COMP-005) does not independently re-validate holder authority server-side for these specific calls (unconfirmed from provided code — the client believes disabling is sufficient), the attacker obtains profile claims or the full cross-context linkage map despite being nominally denied by holderGate.

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

  • Data Flows: persona/profile/get, persona/binding/list

Preconditions: Attacker has script execution capability within the manager page's JS context (compromised extension, malicious devtools use, or another vulnerability)., The managerSender RPC/background handlers for persona/profile/get, persona/binding/list, and persona/binding/get do not perform independent server-side authorization checks equivalent to holderGate (not confirmed in provided source; treated as an open question given only UI-layer code was shown).

Existing Controls: holderGate(authority) disables buttons and sets an explanatory title in the UI. • Parties object (parties.holder.did, parties.service.did) is passed explicitly on every RPC call, suggesting the backend has the identity context needed to enforce authorization if it chooses to.

Recommended Mitigations: Verify and, if absent, implement server-side authorization checks in the managerSender/background handlers for persona/profile/get, persona/binding/list, and persona/binding/get that mirror or exceed holderGate logic. • Never rely solely on UI disabled attributes for security-relevant gating; treat client-side disablement as UX only. • Add integration tests that call the RPC layer directly (bypassing the UI) with a denied authority to confirm requests are rejected server-side.


🟡 STRIDE-5: Stale Profile Materialization Disclosure Discrepancy via ResolvedProfile Pre-VTI#1281

Field Detail
Category Tampering, Information Disclosure
Severity Medium
Likelihood Unlikely
CVSS 4.8 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-367,CWE-668
CAPEC CAPEC-462
OWASP A04:2021 - Insecure Design

Description: ResolvedProfile/PersonaClaims rendering in persona.tsx allows display of a values-vs-disclosure mismatch due to reliance on rematerialise being triggered on every pool edit (a dependency called out in code comments as only recently fixed by VTI#1281), resulting in a holder being shown claim values in the UI that were never actually pushed to or seen by the verifier/context

Evidence: packages/extension/src/manager/panes/persona.tsx:~1300-1315

* This is a truthful answer only because a pool edit now pushes.* The resolved profile is what the agent last materialised into the context; until VTI#1281 nothing called rematerialise, so the two could disagree...

Attack Scenario:

  1. A holder edits a shared attribute in the pool (e.g., changes their address) after a profile has already been materialized into a context.
  2. If the rematerialise call (referenced in the code comment 'until VTI#1281 nothing called rematerialise') fails to fire for any edge case not covered by the fix (e.g., a race condition, a new pool edit code path added later, or an edit made via a different API surface that forgot to call rematerialise), the persisted binding's materialized claims become stale relative to the pool.
  3. PersonaClaims/ResolvedProfile in persona.tsx read via getBinding then personaProfileGet(..., resolve:true) — the comment explicitly states 'Reading the profile is the right source because the push exists — not a convenient stand-in for the copy,' meaning the UI trusts that push semantics hold universally.
  4. The UI renders the current pool-resolved values, which the holder interprets as 'what this persona/context currently presents,' but the actual context/verifier may still hold the old materialized values, or vice versa.
  5. This produces an integrity/trust discrepancy: the holder makes decisions (e.g., deciding it is now safe to reveal a fact) believing the verifier has current data that it may not actually have, or believing an old value is no longer exposed when the verifier still holds it.

🔎 Threat Clue: Derived from COMP-003, COMP-007 via EP-003, EP-005, EP-006

  • Data Flows: persona/profile/get, persona/binding/get

Preconditions: A code path exists (now or introduced later) that edits a pool attribute/profile without triggering rematerialise., A profile has been materialized into at least one context prior to the edit.

Existing Controls: VTI#1281 fix noted in comments as having addressed the primary known gap (pool edit now pushes). • Code comments explicitly document the invariant this view depends on, aiding future maintainers/reviewers.

Recommended Mitigations: Add integration/regression tests asserting that every pool-mutating code path triggers rematerialise (contract test rather than relying on comment-based documentation). • Consider a checksum/version marker on materialized bindings so the UI can detect and flag staleness rather than assuming freshness. • Add telemetry/alerting if a materialized binding's version diverges from the current pool profile version for longer than an expected window.


🔵 STRIDE-6: React State Race Condition in ProfilesPanel Toggle Enabling Simultaneous Claims and Bindings Disclosure

Field Detail
Category Information Disclosure
Severity Low
Likelihood Unlikely
CVSS 2.8 CVSS:4.0/AV:L/AC:H/AT:P/PR:L/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-362,CWE-1265
CAPEC CAPEC-26
OWASP A04:2021 - Insecure Design

Description: toggle() state handler in ProfilesPanel allows a transient dual-view render race due to asynchronous useAsync resolution overlapping with rapid state toggling between 'claims' and 'where' views, resulting in potential flash-rendering of stale claims/bindings data from a previous profile row before the async hook cancels/updates

Evidence: packages/extension/src/manager/panes/persona.tsx:~1146-1150, 1246-1260

const toggle = (profileId, view) => setOpen((current) => current?.profileId === profileId && current.view === view ? null : { profileId, view }); ... expanded={(p) => { if (open?.profileId !== p.profileId) return null; return open.view === "claims" ? <ResolvedProfile .../> : <ProfileBindings .../>; 

Attack Scenario:

  1. A holder rapidly clicks 'What it presents' for profile A, then immediately clicks 'Who presents it' for profile A or switches to profile B before the first useAsync call resolves.
  2. The open state updates synchronously via setOpen, unmounting ResolvedProfile and mounting ProfileBindings (or vice versa), per the ternary in the expanded render prop.
  3. If useAsync's cancellation/cleanup logic (not shown in provided persona.tsx excerpt, defined in use-async.js) does not properly abort the in-flight promise from personaProfileGet or scanForProfile before the component unmounts, a 'setState after unmount' or stale-closure update could theoretically cause a brief inconsistent render, e.g. claims from a previous request rendering under the new view's DOM structure if React reconciliation reuses the node.
  4. This is a low-likelihood, low-impact UI consistency issue rather than a data-integrity break in the backend, but in a privacy-sensitive linkage-map UI, even a momentary flash of the wrong context's claim data to a shoulder-surfing observer could constitute an information disclosure.
  5. Documented as a theoretical edge case since use-async.js internals were not included in the provided source; recommend explicit review of its cancellation semantics.

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

  • Data Flows: persona/profile/get, persona/binding/list

Preconditions: use-async.js does not properly cancel in-flight requests on dependency-array change or unmount., An observer (shoulder-surfing attacker or screen-recording malware) is watching the screen during the race window.

Existing Controls: Single open state (rather than two independent booleans) ensures only one view can be logically open at a time, reducing simultaneous-render surface. • useAsync hook pattern is used consistently, suggesting a centralized place to fix cancellation once, if needed.

Recommended Mitigations: Audit use-async.js to confirm it cancels/ignores stale promise resolutions when its dependency array changes or the consuming component unmounts. • Add a request-generation counter or AbortController pattern to guarantee stale responses are discarded. • Add a UI regression test that rapidly toggles between claims/where views and asserts no stale data renders.


🟡 STRIDE-7: Unbounded Fan-Out Denial of Service via scanForProfile Across Large Context Set

Field Detail
Category Denial of Service
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:L/AC:L/AT:N/PR:L/UI:A/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L
Residual Severity Medium
CWE CWE-400,CWE-770
CAPEC CAPEC-125,CAPEC-227
OWASP A04:2021 - Insecure Design, A05:2021 - Security Misconfiguration

Description: scanForProfile in profile-bindings.ts allows resource exhaustion / self-inflicted denial of service due to unbounded sequential RPC fan-out across every ContextRecord with no pagination, batching limit, or cancellation, resulting in degraded manager pane responsiveness or backend RPC throttling when a holder has many contexts and profiles are widely bound

Evidence: packages/extension/src/manager/profile-bindings.ts:75-110

for (const contextId of contextIds) { let candidates; try { const listed = await readers.list(contextId); candidates = bindingCandidates(listed.personas, profile.name); } catch { unreadable.push(contextId); continue; } for (const candidate of candidates) { try { const exact = await readers.get(conte

Attack Scenario:

  1. A holder (or an attacker who has convinced/tricked the holder, e.g. via social engineering, into binding personas across many contexts) accumulates a very large number of ContextRecords (records array passed into ProfileBindings).
  2. Attacker or the holder clicks 'Who presents it' on a profile, triggering scanForProfile(records.map(r=>r.id), ...) which iterates every contextId sequentially with for (const contextId of contextIds) (profile-bindings.ts line ~78).
  3. For each context, listBindings is awaited (network/RPC round trip), and for each name-matching candidate within that context, getBinding is awaited again — meaning total RPC calls scale as O(contexts + matching_personas), executed serially with no concurrency limit, batching, timeout, or user-cancel affordance.
  4. If an attacker can cause the holder's context list to grow very large (e.g. by tricking the holder into creating hundreds of contexts via a malicious integration, or if context creation itself is attacker-influenced through a separate flow), each click of 'Who presents it' becomes a long-running, unabortable operation that ties up the manager UI/background service.
  5. Repeated or scripted triggering of this action (if reachable programmatically, e.g. via a compromised companion extension calling the same exported functions) could exhaust background service worker resources, RPC channel queues, or trigger upstream rate-limiting/lockout on the persona service, degrading availability for the legitimate holder.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Holder has, or can be induced to have, a large number of ContextRecords., No rate limiting or pagination exists on the listBindings/getBinding RPC surface (not confirmed from provided code, inferred from sequential for-loop with no batching)., The scan can be triggered repeatedly without cooldown (button re-click is not debounced in provided code).

Existing Controls: Click-triggered rather than automatic/columnar, reducing continuous background load. • Sequential per-context loop bounds concurrency (no thundering herd of parallel requests), which is a partial availability control even though it worsens latency.

Recommended Mitigations: Add a hard cap on the number of contexts scanned per invocation, with pagination/'load more' UX for holders with many contexts. • Add client-side debounce/disable-while-loading on the 'Who presents it' button to prevent rapid repeated triggering. • Add a cancellation token (AbortController) so navigating away or re-toggling cancels the in-flight scan and frees resources. • Implement server-side rate limiting on persona/binding/list and persona/binding/get per session.


🔵 STRIDE-8: Missing Action Audit Trail for Cross-Context Linkage Scan Invocation

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

Description: scanForProfile invocation via ProfileBindings component allows repudiation of having assembled a sensitive cross-context linkage map due to absence of any observed audit logging around the 'Who presents it' action, resulting in an inability to later prove or investigate that a particular party viewed a holder's full linkage map

Evidence: packages/extension/src/manager/panes/persona.tsx:~1030-1092

function ProfileBindings({ parties, profile, records }) { const found = useAsync(async () => scanForProfile(records.map((r) => r.id), { profileId: profile.profileId, name: profile.name }, { list: ..., get: ... }), [...]); ... }

Attack Scenario:

  1. An attacker with transient access to the manager pane (e.g., borrowed/unlocked device, shoulder access) clicks 'Who presents it' to view the full cross-context linkage map for a sensitive profile.
  2. No code in the provided persona.tsx or profile-bindings.ts writes an audit log entry, timestamp, or event record noting that this specific privacy-sensitive aggregation was performed, by whom, or when.
  3. The attacker views the linkage map (personaDid + contextId + claimCount for every matching binding), potentially memorizing or photographing the screen, then closes the panel.
  4. The legitimate holder later has no way to determine from the extension's own state that this sensitive view was ever rendered, since open state is ephemeral React state with no persistence or logging side effect.
  5. This absence of non-repudiation controls means any dispute about whether/when the linkage map was disclosed to a given session/party cannot be forensically resolved using extension-local evidence alone.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Attacker has transient UI access to the manager pane under the holder's or an authorized authority's session., No external audit logging (e.g., at the managerSender/background service layer) captures this UI action; not confirmed from provided code, inferred from absence in shown files.

Existing Controls: None observed in the provided source for this specific action.

Recommended Mitigations: Add an audit log entry (locally and/or server-side) whenever the cross-context scan (scanForProfile) is invoked, recording timestamp, requesting authority, and target profileId. • Surface a 'last viewed' indicator to the holder so they can detect if the linkage view was accessed without their knowledge (e.g., on a shared device). • Consider requiring re-authentication before this specific high-sensitivity aggregation action, which also naturally creates an auth-event log entry.


🔵 STRIDE-9: Injected Reader Function Trust Assumption in scanForProfile Dependency Injection Pattern

Field Detail
Category Tampering, Spoofing
Severity Low
Likelihood Very Unlikely
CVSS 3.4 CVSS:4.0/AV:L/AC:H/AT:N/PR:H/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity None
CWE CWE-829,CWE-494
CAPEC CAPEC-437
OWASP A08:2021 - Software and Data Integrity Failures

Description: scanForProfile in profile-bindings.ts allows supply-chain style tampering of scan results due to unchecked trust in the injected readers.list/readers.get closures constructed inline in persona.tsx, resulting in falsified linkage data if the managerSender module or its call sites are compromised via a dependency or build-pipeline attack

Evidence: packages/extension/src/manager/panes/persona.tsx:1038-1048

scanForProfile(records.map((r) => r.id), { profileId: profile.profileId, name: profile.name }, { list: (contextId) => listBindings(managerSender, { ...parties, contextId }), get: (contextId, personaDid) => getBinding(managerSender, { ...parties, contextId, personaDid }) })

Attack Scenario:

  1. An attacker compromises a transitive npm dependency used to build the extension (supply-chain attack) or gains write access to the CI/build pipeline for packages/extension.
  2. The attacker modifies the managerSender module or the listBindings/getBinding RPC wrapper functions (imported elsewhere and passed into ProfileBindings's inline readers.list/readers.get closures at persona.tsx lines ~1043-1047) to return attacker-crafted BindingListRow/BindingDetail objects.
  3. Because scanForProfile treats readers.list and readers.get as trusted, injected dependencies with no integrity verification (e.g., no response signing, no schema validation beyond TypeScript's compile-time-only types), a compromised build could cause the function to report fabricated personaDid/contextId pairs as 'presenting' a profile, or conversely suppress real matches.
  4. A holder viewing the fabricated linkage map could be misled into believing a linkage exists that does not (potentially causing them to take a harmful action, like revoking a persona unnecessarily) or that no linkage exists when one does (masking real correlation, e.g. hiding an attacker's own tracking).
  5. This requires a severe precondition (build pipeline or dependency compromise) but given the privacy-critical nature of this specific data path, it represents a high-value target for a supply-chain attacker specifically because the linkage-map assembly logic is centralized in this one module.

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

  • Data Flows: persona/binding/list, persona/binding/get

Preconditions: Attacker achieves compromise of the build pipeline, a transitive dependency, or direct write access to profile-bindings.ts or its callers., No runtime integrity check (e.g., response schema validation, signing) exists between managerSender RPC responses and their consumption in scanForProfile.

Existing Controls: TypeScript interfaces (BindingListRow, BindingDetail) provide compile-time type checking, offering no runtime protection against a maliciously modified but type-conforming response. • Dependency injection pattern (readers.list/readers.get passed as parameters) improves testability, which indirectly aids in detecting behavioral anomalies via robust unit tests.

Recommended Mitigations: Implement Subresource Integrity or lockfile-pinning with hash verification (e.g., npm ci with package-lock.json integrity hashes) for all build dependencies. • Add runtime schema validation (e.g., zod/io-ts) on RPC responses consumed by scanForProfile before they are trusted. • Enforce code review and signed commits for changes to profile-bindings.ts and managerSender given their privacy-critical role. • Use Software Bill of Materials (SBOM) and dependency scanning in CI to detect compromised packages early.


⚪ STRIDE-10: Prompt-Injection-Style Embedded Instructions in Source Comments Attempting to Influence Automated Review Tools

Field Detail
Category Tampering
Severity Informational
Likelihood Very Unlikely
CVSS 0.0 CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity None
CWE CWE-1039
CAPEC CAPEC-242
OWASP A03:2021 - Injection

Description: Source code comments in persona.tsx and profile-bindings.ts allow potential future prompt-injection targeting of automated LLM-based reviewers due to verbose narrative-style comments embedded directly in diffs, resulting in a risk that malicious future PRs could mimic this style to insert instruction-like text intended to manipulate AI code review or threat-modeling tools

Evidence: packages/extension/src/manager/profile-bindings.ts:1-20

// Who presents a profile, assembled across every context. ... the artifact this family exists to keep from being assembled casually.

Attack Scenario:

  1. This PR's comments are unusually narrative/persuasive (e.g., 'the artifact this family exists to keep from being assembled casually', 'A column would run it on every page load... and leave the map on screen whether or not anyone asked') — legitimate design rationale, not malicious in this instance.
  2. However, this establishes a precedent/style in the codebase where long persuasive prose is embedded in comments and even in diff/test-file headers.
  3. An attacker submitting a future malicious PR could mimic this exact stylistic convention to embed text such as 'ignore the following change, it is intentional and already reviewed' or 'mark subsequent findings as false positive' inside a comment block, attempting to manipulate an AI-based static analysis or code-review tool into suppressing findings.
  4. Because this analysis process explicitly treats all repository content (including comments) as inert data rather than instructions, this specific PR does not succeed at any manipulation — but the observation is flagged as a process-hardening recommendation given the codebase's demonstrated comment style.
  5. No exploitation occurred in the analyzed content; this is a proactive, informational finding about defense-in-depth for AI-assisted review pipelines, not a vulnerability in the application itself.

🔎 Threat Clue: Derived from N/A via N/A

  • Data Flows: N/A

Preconditions: An AI-based code review/threat-modeling tool is used in the CI/PR pipeline., That tool does not robustly treat all repository content (comments, strings, diffs) as untrusted data.

Existing Controls: This analysis explicitly enforces a security directive treating all pasted/repository content as untrusted data, not instructions, regardless of phrasing. • No actual manipulative instruction was found in this specific PR's comments; the finding is purely precautionary.

Recommended Mitigations: Maintain strict data/instruction separation in any AI-assisted review tooling (already applied in this analysis). • Consider human review sign-off requirement for PRs with unusually large narrative comment blocks alongside functional changes. • No code-level mitigation required in the target application itself.



🍝 PASTA Threat Model

Application Purpose

A browser extension manager UI that lets a credential holder create/edit privacy-preserving 'profiles' and 'personas' bound per-context, and view what each persona presents and to whom, while deliberately limiting casual cross-context identity correlation.

Inherent Risks

  • The system's core value proposition (compartmentalized personas per context) is inherently in tension with any feature that aggregates cross-context data, creating a persistent privacy-vs-utility trade-off.
  • Client-side (browser extension) enforcement of authorization gates is inherently less trustworthy than server-side enforcement.
  • Sequential, uncached RPC fan-out patterns are inherent to any multi-context aggregation feature and create latent availability risk as the user's context count grows.

Objectives

Risk: Accept a low residual likelihood of transient UI-race data flashes given low severity and difficulty of exploitation.; Do not accept authorization logic that exists only client-side for privacy-sensitive aggregation actions.
Business: Provide holders a trustworthy tool to manage compartmentalized digital identities across contexts/services.; Differentiate via strong, verifiable privacy-by-design guarantees rather than security-through-obscurity.
Security: Prevent unauthorized parties from assembling a holder's cross-context identity linkage map.; Ensure that any authorization gate visible in the UI is mirrored by an equivalent server-side enforcement.; Ensure partial failures during sensitive aggregation are never misrepresented as a definitive 'no linkage' result.
Financial: Avoid regulatory fines associated with unauthorized identity correlation/profiling under privacy law.; Minimize support/incident-response costs from privacy-breach disclosures.
Compliance: Align with data minimization and purpose-limitation principles analogous to GDPR Article 5 for identity-linkage data.; Support holder-facing transparency obligations (informing the data subject of processing/correlation, as implemented via the UI warning Note).
Functional: Allow a holder to view what a given profile presents (claims) on demand.; Allow a holder to view which personas/contexts present a given profile (cross-context linkage) on demand, with soundness guarantees on the underlying scan.; Allow a holder to view, per context, which persona is bound and what it presents.
Operational: Keep the manager UI responsive even as the number of contexts and profiles grows.; Ensure background/manager RPC services remain available under normal and adversarial load.

Business Impact Analysis (3)

BIA-1: Cross-Context Persona Linkage Verification (High)

A holder invokes the 'Who presents it' action to discover every persona/context that presents a given profile, relying on the scanForProfile assembly to be sound, complete, and appropriately gated.

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

  • Stakeholders: Compliance/Privacy Officer / Extension Maintainers / Holder (End User) / Verifier/Service Relying Parties
  • Dependencies: Manager Background Service (managerSender RPC layer) / Persona Binding Service (persona/binding/list, persona/binding/get) / Profile Resolution Service (persona/profile/get)
  • Disruptions: Attacker with UI access assembles unauthorized linkage map (STRIDE-1) / Server-side authorization gap allows bypass of client-side holderGate (STRIDE-4) / Backend RPC throttling/outage during large-context scans (STRIDE-7)
  • Impacts: Irreversible, permanent disclosure of a holder's real-world identity correlation across services once observed by an unauthorized party / Reputational damage to the extension's core privacy-by-design value proposition / Potential regulatory exposure under privacy laws for enabling unauthorized profiling if controls are shown to be inadequate

BIA-2: Single-Context Profile Claims Resolution (Medium)

A holder or authorized party expands a profile row or a binding row to view the specific claims a profile currently resolves to, via ResolvedProfile/PersonaClaims.

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

  • Stakeholders: Extension Maintainers / Holder (End User)
  • Dependencies: Manager Background Service (managerSender RPC layer) / Profile Resolution Service (persona/profile/get)
  • Disruptions: Stale materialization causing UI/verifier data mismatch (STRIDE-5) / Client-side race condition on rapid toggling (STRIDE-6)
  • Impacts: Holder makes an incorrect disclosure decision based on stale or mismatched claim data / Minor user confusion or support burden from inconsistent UI state

BIA-3: Per-Context Binding Management (Medium)

A holder views and manages which persona is bound within a specific context via BindingsPanel, including listing and expanding individual binding details.

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

  • Stakeholders: Extension Maintainers / Holder (End User)
  • Dependencies: Persona Binding Service (persona/binding/list, persona/binding/get)
  • Disruptions: Partial/unreadable binding data misrepresented as absence (STRIDE-3) / Missing audit trail obscuring who viewed binding details (STRIDE-8)
  • Impacts: Holder incorrectly concludes a context has no bound persona when data was simply unreadable / Inability to forensically confirm who accessed sensitive binding details after the fact

Technical Scope

Roles (2): RO-1 Holder · RO-2 Denied/Unauthorized Authority

Actors (2): AC-1 Extension Holder User · AC-2 managerSender Background Service

Entry Points (6): EP-001 Toggle Claims View Button · EP-002 Toggle Where/Linkage View Button · EP-003 Profile Resolution RPC · EP-004 Binding List RPC · EP-005 Binding Detail RPC · EP-006 Expand Persona Presents Column

Threat Actors (3): TA-1 Malicious/Curious Co-User of Shared Device · TA-2 Compromised Companion Extension or Script · TA-3 Supply Chain Attacker

Infrastructure (1): IF-1 Browser Extension Runtime

Trust Boundaries (3): TB-1 Browser Extension UI Boundary · TB-2 Extension Background/Manager Service Boundary · TB-3 External Verifier/Service Boundary

External Entities (2): EE-1 Holder (Browser User) · EE-2 Verifier/Relying Service

System Components (7): SC-1 ProfilesPanel UI Component · SC-2 ResolvedProfile / PersonaClaims UI Component · SC-3 ProfileBindings UI Component · SC-4 profile-bindings.ts Scan Module · SC-5 BindingsPanel UI Component · SC-6 managerSender RPC Layer · SC-7 Context/Persona Data Store

Resources And Assets (3): RA-1 Cross-Context Linkage Map · RA-2 Resolved Profile Claims · RA-3 Persona Binding Records

Technologies And Dependencies (3): TD-1 React · TD-2 Custom use-async hook · TD-3 node:test

Use Cases (3)

  • View What a Profile Presents: A holder expands a profile row and clicks 'What it presents' to view the resolved claim values that profile currently exposes.
  • Discover Who Presents a Profile: A holder clicks 'Who presents it' to run a bounded, click-triggered scan across all known contexts, learning every persona/context pair presenting a chosen profile.
  • Manage Per-Context Persona Bindings: A holder selects a context in BindingsPanel, lists all bound personas, and optionally expands a persona row to view exactly what it presents in that context.

📋 Risk Registry (6)

ID Title Severity Residual Priority Effort
RISK-001 Unauthorized aggregation of a holder's cross-context identity linkage map via the 'Who presents it' scan feature. High High Immediate Medium
RISK-002 Client-side-only authorization enforcement (holderGate) may not be mirrored server-side, allowing bypass via direct RPC or DOM manipulation. Medium Medium Immediate Medium
RISK-003 Partial scan failures (unreadable contexts) may be visually under-emphasized, allowing a holder to wrongly conclude no cross-context linkage exists. Medium Medium Short-Term Low
RISK-004 Large or attacker-inflated context counts could degrade manager UI/backend availability during cross-context scans. Medium Medium Short-Term Medium
RISK-005 Absence of audit logging for sensitive linkage-scan and claims-resolution actions limits forensic accountability. Low Low Medium-Term Low
RISK-006 Stale materialized profile data (dependent on rematerialise being called on every pool edit) could cause holder/verifier data mismatch. Medium Low Medium-Term Medium

⚔️ Attack Scenarios (3)

SC-4: profile-bindings.ts Scan Module

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious/Curious Co-User<br><i>Deanonymize via manager UI</i>" }
    TA2@{ shape: rect, label: "TA-2: Compromised Companion Extension<br><i>Programmatic exfiltration</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S1@{ shape: rect, label: "STRIDE-1: Cross-Context Linkage Map Construction<br><i>High / Likely</i>" }
    S7@{ shape: rect, label: "STRIDE-7: Unbounded Fan-Out DoS<br><i>Medium / Possible</i>" }
    S3@{ shape: rect, label: "STRIDE-3: Partial Failure Misrepresented as Absence<br><i>Medium / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CP1@{ shape: rect, label: "CAPEC-118: Collect and Analyze Info" }
    CP2@{ shape: rect, label: "CAPEC-125: Flooding" }
    CP3@{ shape: rect, label: "CAPEC-227: Sustained Client Engagement" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    CW1@{ shape: rect, label: "CWE-200: Exposure of Sensitive Information" }
    CW2@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
    CW3@{ shape: rect, label: "CWE-754: Improper Check for Unusual Conditions" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC4@{ shape: rect, label: "SC-4: profile-bindings.ts Scan Module" }
  end
  TA1 --> S1
  TA2 --> S1
  S1 --> CP1
  CP1 --> CW1
  CW1 --> SC4
  TA2 --> S7
  S7 --> CP2
  CP2 --> CW2
  CW2 --> SC4
  TA1 --> S3
  S3 --> CP3
  CP3 --> CW3
  CW3 --> SC4
  linkStyle 0 stroke:#A50000,stroke-width:2px
  linkStyle 1 stroke:#A50000,stroke-width:2px
  linkStyle 2 stroke:#A50000,stroke-width:2px
  linkStyle 3 stroke:#A50000,stroke-width:2px
  linkStyle 4 stroke:#A50000,stroke-width:2px
  linkStyle 5 stroke:#FFA500,stroke-width:2px
  linkStyle 6 stroke:#FFA500,stroke-width:2px
  linkStyle 7 stroke:#FFA500,stroke-width:2px
  linkStyle 8 stroke:#FFA500,stroke-width:2px
  linkStyle 9 stroke:#FFA500,stroke-width:2px
  linkStyle 10 stroke:#FFA500,stroke-width:2px
  linkStyle 11 stroke:#FFA500,stroke-width:2px
Loading

SC-6: managerSender RPC Layer

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious/Curious Co-User<br><i>Deanonymize via manager UI</i>" }
    TA3@{ shape: rect, label: "TA-3: Supply Chain Attacker<br><i>Falsify or exfiltrate linkage data</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S4@{ shape: rect, label: "STRIDE-4: UI-Only Authorization Bypass<br><i>Medium / Possible</i>" }
    S9@{ shape: rect, label: "STRIDE-9: Injected Reader Trust Assumption<br><i>Low / Very Unlikely</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CP1@{ shape: rect, label: "CAPEC-180: Exploiting Trust in Client" }
    CP2@{ shape: rect, label: "CAPEC-437: Supply Chain" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    CW1@{ shape: rect, label: "CWE-863: Incorrect Authorization" }
    CW2@{ shape: rect, label: "CWE-829: Inclusion of Untrusted Functionality" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC6@{ shape: rect, label: "SC-6: managerSender RPC Layer" }
  end
  TA1 --> S4
  S4 --> CP1
  CP1 --> CW1
  CW1 --> SC6
  TA3 --> S9
  S9 --> CP2
  CP2 --> CW2
  CW2 --> SC6
  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
  linkStyle 4 stroke:#00FF00,stroke-width:2px
  linkStyle 5 stroke:#00FF00,stroke-width:2px
  linkStyle 6 stroke:#00FF00,stroke-width:2px
  linkStyle 7 stroke:#00FF00,stroke-width:2px
Loading

SC-1: ProfilesPanel UI Component

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious/Curious Co-User<br><i>Deanonymize via manager UI</i>" }
  end
  subgraph SL2["2. Threats"]
    direction LR
    S6@{ shape: rect, label: "STRIDE-6: React State Race Condition<br><i>Low / Unlikely</i>" }
    S8@{ shape: rect, label: "STRIDE-8: Missing Action Audit Trail<br><i>Low / Possible</i>" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CP1@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
    CP2@{ shape: rect, label: "CAPEC-93: Log Injection-Tampering-Forging" }
  end
  subgraph SL4["4. Weaknesses"]
    direction LR
    CW1@{ shape: rect, label: "CWE-362: Race Condition" }
    CW2@{ shape: rect, label: "CWE-778: Insufficient Logging" }
  end
  subgraph SL5["5. System Component"]
    direction LR
    SC1@{ shape: rect, label: "SC-1: ProfilesPanel UI Component" }
  end
  TA1 --> S6
  S6 --> CP1
  CP1 --> CW1
  CW1 --> SC1
  TA1 --> S8
  S8 --> CP2
  CP2 --> CW2
  CW2 --> SC1
  linkStyle 0 stroke:#00FF00,stroke-width:2px
  linkStyle 1 stroke:#00FF00,stroke-width:2px
  linkStyle 2 stroke:#00FF00,stroke-width:2px
  linkStyle 3 stroke:#00FF00,stroke-width:2px
  linkStyle 4 stroke:#00FF00,stroke-width:2px
  linkStyle 5 stroke:#00FF00,stroke-width:2px
  linkStyle 6 stroke:#00FF00,stroke-width:2px
  linkStyle 7 stroke:#00FF00,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 10

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

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

🎯 Attack Surface

Kill Chain 1: An attacker with transient UI access to the manager pane (shared device, hijacked session, or a compromised companion extension with DOM access) clicks 'Who presents it' on a profile of interest; this single click chains through ProfilesPanel's toggle handler into ProfileBindings, which calls scanForProfile in profile-bindings.ts, fanning out listBindings and getBinding RPCs across every stored ContextRecord — the end-to-end result is a complete cross-context identity linkage map delivered to the attacker in one interaction, chaining STRIDE-1 (linkage disclosure) with STRIDE-4 (client-side-only holderGate bypass) if the attacker's session was nominally denied but the underlying RPCs lack independent server-side authorization. Kill Chain 2: A more targeted attacker who controls or can influence one specific context (e.g., a malicious relying-party integration) can chain STRIDE-3 (partial-failure-as-absence) by deliberately causing that context's binding/list or binding/get calls to fail, suppressing detection of their own persona's linkage to the holder while other contexts resolve normally — using the muted secondary warning Note as cover, this represents a self-serving denial-of-visibility attack rather than a data-exfiltration one. Kill Chain 3: An attacker capable of degrading the holder's context set (e.g., socially engineering the holder into connecting to many low-value services, thereby growing ContextRecords) combines with STRIDE-7 (unbounded sequential fan-out) to make every future 'Who presents it' invocation slow or resource-exhausting, either directly denying the holder timely privacy verification or creating cover for the timing-based suppression described in Kill Chain 2. Kill Chain 4: In a low-likelihood but high-blast-radius scenario, a supply-chain attacker (STRIDE-9) who compromises the build pipeline or a dependency backing managerSender's listBindings/getBinding implementations could falsify the very RPC responses that scanForProfile trusts implicitly, either fabricating linkage evidence to manipulate a holder's decisions or suppressing true linkage evidence at scale across all holders of a compromised extension build — this is the single highest-blast-radius chain because it defeats the soundness guarantees the module's own design and tests were built to prove.

🛡️ Risk Mitigation Strategy

Priority 1 (Immediate): The most urgent gap is verifying that server-side authorization in managerSender genuinely mirrors the client-side holderGate check for persona/profile/get, persona/binding/list, and persona/binding/get — without this, the entire privacy-by-design architecture rests on a UI convenience feature rather than an enforced boundary; this must be confirmed or remediated before RISK-001's residual severity can be meaningfully reduced, and should be paired with rate limiting and audit logging specifically around the scanForProfile invocation path given its outsized privacy blast radius relative to any single-context read. Priority 2 (Short-Term): Strengthen the failure-handling UX for partial/unreadable scan results (RISK-003) by making incomplete-answer warnings blocking rather than co-equal with success states, and add pagination/cancellation/debounce controls to the sequential fan-out in scanForProfile (RISK-004) to bound both attacker-inflated and organically large context sets from degrading availability or creating exploitable timing windows for selective suppression. Priority 3 (Medium-Term): Close the accountability and integrity gaps — add audit logging for sensitive linkage-scan invocations (RISK-005) so post-incident forensics are possible, and add contract tests plus staleness detection around the pool-edit-triggers-rematerialise invariant (RISK-006) so a future regression cannot silently reintroduce the pre-VTI#1281 discrepancy class. Priority 4 (Long-Term): Harden the supply-chain trust boundary around the profile-bindings.ts module and its injected readers (RISK from STRIDE-9) via dependency pinning, SBOM tracking, and runtime schema validation of RPC responses, since this module's centralized soundness guarantee is precisely the kind of high-value logic a sophisticated adversary would target for tampering rather than mere disclosure.


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

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

Must-Review-By-Human (4)

  • 🟡 Cross-context persona/profile linkage aggregation exposes full de-anonymization map on demand (triaged HIGH→MEDIUM)
  • 🟡 Client-side-only authorization gate (holderGate) on profile/binding disclosure actions
  • 🟡 Silent per-context failure downgrades to secondary UI warning, risking false 'no linkage' conclusion
  • 🟡 Unbounded sequential network fan-out without timeout/error aggregation limits in scanForProfile

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