Skip to content

feat(rp-login): bind a persona to a site on first sign-in, not beforehand - #144

Merged
stormer78 merged 1 commit into
mainfrom
feat/first-use-persona-binding
Aug 30, 2026
Merged

feat(rp-login): bind a persona to a site on first sign-in, not beforehand#144
stormer78 merged 1 commit into
mainfrom
feat/first-use-persona-binding

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

proxyLogin required the page to name a vault entry, so a per-site persona had to be bound in advance through the vault panel and a site without one dead-ended. The page's only route to an entry id was vaultList() — a second consent prompt that enumerates the user's vault to the site asking — and on a site with nothing bound it returned an empty array with nowhere to go from there.

In practice that made the per-site persona a setup step people did not do, which left login() and its holder DID as the path of least resistance.

The wallet now resolves the entry itself, from the origin the browser attested, and asks the human once when there is nothing bound yet.

// what an RP page writes now
await window.vtaWallet.proxyLogin({ nonce });

entryId becomes optional. A page that still supplies one keeps the old behaviour exactly.

One prompt, not two

Choosing the identity a site sees is the sign-in approval — it names the site and the identity, which is what the prompt already claims to do. A picker in front of a second approve screen would only train the operator to click through both (R7.2).

First visit gets the existing consent window with a persona dropdown, the full DID beneath it, and the ACL note. Approve binds the entry and continues straight into the login. Every later visit is the prompt that was already there.

The picker bypasses the origin-trust short-circuit, deliberately

The first-use branch calls requestConsent, not gatedConsent. A "remember this site" tick made against an earlier sign-in meant "log me in as the identity I already chose for you". It cannot mean "choose a new identity for me and bind it silently" — that question has never been put to the operator.

Same reasoning requestTaskConsent already carries: origin trust is not capability trust. Delete an entry and the picker returns, trusted origin or not.

The origin match is local and exact

vault/list narrows by targetOriginPrefix, and a prefix is not an origin — https://shop.example is a prefix of https://shop.example.evil.test. Narrowing the set the VTA sends is a bandwidth decision; deciding which entry is this site's is a security decision, so it happens here with ===. Tested in both directions, plus scheme and port.

The prompt returns a DID string and nothing else

Context and signing key are re-derived in the background from the agent's own list-dids and derive-signing-key-id, so a DID the agent does not host cannot be bound whatever the consent window sends back.

A persona with more than one candidate signing key is refused rather than guessed — which key mints the id_token is a real choice with no default, and a wrong guess fails later and opaquely at the VTA. The operator is sent to the vault panel, which has the key picker this prompt deliberately does not.

The ACL caveat, said before the failure

The relying party decides which identities it admits and nothing this wallet does can add one. So it is stated in the prompt beside the DID, while it is still on screen and copyable — not only after the login is refused. A failed first sign-in repeats it and names the DID.

The entry is kept on failure: it is correct, and deleting it would make the retry-after-enrolment path ask for an identity all over again.

Scope

Entries are bound to {kind:"webOrigin"} and the RP's DID when the page named one. The vault panel's did-self-issued form binds a DID target only, so entries created there stay invisible to an origin lookup — left alone rather than migrated, since nothing is deployed and the panel is still the place to bind by hand.

login() (REST SIOP) is untouched and still signs as the holder DID for every site. That path reads no vault entry at all, so aligning it is a behaviour change to a working flow and wants its own decision. Worth naming, because signin-flow.tsx tells the user "each site gets its own identity" and on that path it does not.

Creating a new persona DID is not in scope. webvhDidCreate exists in core and has no call site in the extension; the picker offers what the agent already hosts.

Also

Decision logic sits in first-use-profile.ts, free of chrome so it is testable. The demo harness gains a proxyLogin({}) button — the whole point of #140 was that a flow nothing exercises stays broken.

Pre-merge checklist

  • npm run lint clean (tsc -b, not --noEmit)
  • npm run build clean
  • npm test — 656 tests, 0 failures (11 new)
  • MV3 invariants: dist/background.js single bundle, no dynamic import(), no chrome.cookies, no static content_scripts, no cookies permission
  • Module boundaries + entry points unchanged (packages/core untouched)
  • R1.2 — no new outbound fetch; all VTA traffic reuses existing offscreen handlers
  • R3.7 — no matching on message text; the one new user-facing string is additive context on an error already being returned
  • PAGE_FACING_RUNTIME_TYPES unchanged — list-dids, vault-upsert and derive-signing-key-id are called as background-local functions, never exposed to a page
  • No compatibility fold — entryId widens from required to optional, which needs no dual-accept arm

…hand

`proxyLogin` required the *page* to name a vault entry, so a per-site
persona had to be bound in advance through the vault panel and a site
without one dead-ended. The page's only route to an entry id was
`vaultList()` — a second consent prompt that enumerates the user's vault
to the site asking — and on a site with nothing bound it returned an
empty array with nowhere to go from there. In practice that made the
per-site persona a setup step people did not do, which left `login()`
and its holder DID as the path of least resistance.

The wallet now resolves the entry itself, from the origin the browser
attested, and asks the human once when there is nothing bound yet.
`entryId` becomes optional; a page that still supplies one keeps the old
behaviour exactly.

**One prompt, not two.** Choosing the identity a site sees IS the
sign-in approval — it names the site and the identity, which is what the
prompt already claims to do. A picker in front of a second approve
screen would only train the operator to click through both (R7.2).

**The picker bypasses the origin-trust short-circuit, deliberately.**
The first-use branch calls `requestConsent`, not `gatedConsent`. A
"remember this site" tick made against an earlier sign-in meant "log me
in as the identity I already chose for you"; it cannot mean "choose a
new identity for me and bind it silently", because that question has
never been put to the operator. Same reasoning `requestTaskConsent`
already carries: origin trust is not capability trust. Delete an entry
and the picker returns, trusted origin or not.

**The origin match is local and exact.** `vault/list` narrows by
`targetOriginPrefix`, and a prefix is not an origin —
`https://shop.example` is a prefix of `https://shop.example.evil.test`.
Narrowing the set the VTA sends is a bandwidth decision; deciding which
entry is this site's is a security decision, so it happens locally with
`===`. Tested in both directions, plus scheme and port.

**The prompt returns a DID string and nothing else.** Context and
signing key are re-derived in the background from the agent's own
`list-dids` and `derive-signing-key-id`, so a DID the agent does not
host cannot be bound whatever the consent window sends back. A persona
with more than one candidate signing key is refused rather than guessed
— which key mints the id_token is a real choice with no default, and a
wrong guess fails later and opaquely at the VTA — with the operator sent
to the vault panel, which has the key picker.

Entries are bound to `{kind:"webOrigin"}` *and* the RP's DID when the
page named one. The vault panel's did-self-issued form binds a DID
target only, so entries created there stay invisible to an origin
lookup; that is left alone rather than migrated, since nothing is
deployed and the panel is still the place to bind by hand.

The ACL caveat is stated in the prompt, beside the DID, rather than
after the failure: the relying party decides which identities it admits
and nothing this wallet does can add one, so the operator wants the DID
on screen while it is still copyable. A failed first sign-in repeats it
and names the DID. The entry is kept on failure — it is correct, and
deleting it would make the retry-after-enrolment path ask for an
identity all over again.

`login()` (REST SIOP) is untouched and still signs as the holder DID for
every site. That path reads no vault entry at all, so aligning it is a
behaviour change to a working flow and wants its own decision.

Decision logic sits in `first-use-profile.ts`, free of `chrome` so it is
testable; the demo harness gains a `proxyLogin({})` button, since the
whole point of #140 was that a flow nothing exercises stays broken.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 889aa68 into main Aug 30, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/first-use-persona-binding branch August 30, 2026 20:56
stormer78 added a commit that referenced this pull request Aug 30, 2026
#144 made `proxyLogin({})` resolve-or-bind, and that is enough for an RP
that only needs a token. It is not enough for the two RPs we actually
have. `vtc-service/admin-ui` and `did-hosting-ui` both bind their
`/auth/challenge` to the persona DID:

    POST /auth/challenge { did: entry.principalDid }
    proxyLogin({ entryId, nonce: challenge })

The DID is needed *before* the nonce, so it cannot come out of
`proxyLogin` — and both pages get it today from `vaultList()`, which
enumerates the user's whole vault to answer a question about one entry.
On a site with nothing bound that returns empty and the page gives up,
which is exactly what #144 was meant to stop and could not reach.

`walletProfile({ target })` answers that question and only that one. It
resolves the entry for the browser-attested origin, or raises the
first-use picker and binds the answer, and returns `{ did, entryId,
bound }`. It mints nothing and issues no session.

**`entryId` comes back deliberately.** The objection to a page holding
one was never possession; it was that *learning* one cost a
vault-enumerating prompt. An id handed back for the site's own entry
costs nothing, and passing it to `proxyLogin` saves the lookup this call
just did — so the two-call flow is the same number of VTA round trips as
the `vaultList` + `proxyLogin` it replaces, with one fewer prompt and no
disclosure of the rest of the vault.

**Two prompts on a first sign-in, one after.** Binding raises the
picker; the sign-in raises its own consent. Folding the second into the
first would mean a call that mints nothing silently pre-authorizing one
that does. First contact with a site is the place to ask twice.

An already-bound lookup does not prompt at all: it discloses one DID, to
the site that DID exists for, which is about to receive it inside an
id_token anyway. `principalDid` is read back from the VTA rather than
remembered, since it is maintainer-derived and an entry whose secret was
rotated there would otherwise report a DID it no longer signs as.

Two drift traps found on the way, both now guarded:

  - `provider.ts` inlined a second copy of the `BridgeMethod` union, so
    adding a method failed at its own call site with an error naming
    every method but the new one. It uses the type now.
  - `content.ts` cannot `import` (classic script), so it inlines the
    protocol constants under a "keep these in sync" comment with nothing
    enforcing it. Two invariants ride on that hand-sync: a method routed
    to a type absent from `PAGE_FACING_RUNTIME_TYPES` takes its origin
    from the page's own message body rather than the browser — and every
    vault entry, trust record and pin here is keyed on origin — while a
    mistyped constant routes to no handler and surfaces as a shapeless
    failure. `tests/page-facing-surface.test.mts` reads both sources and
    checks them against each other; its first assertions guard against
    the regexes silently matching nothing.

Requires the RP-side change in OpenVTC/verifiable-trust-infrastructure
and OpenVTC/affinidi-webvh-service; neither wallet call is removed, so
the repos can land in either order.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78 added a commit that referenced this pull request Aug 30, 2026
)

#144 made `proxyLogin({})` resolve-or-bind, and that is enough for an RP
that only needs a token. It is not enough for the two RPs we actually
have. `vtc-service/admin-ui` and `did-hosting-ui` both bind their
`/auth/challenge` to the persona DID:

    POST /auth/challenge { did: entry.principalDid }
    proxyLogin({ entryId, nonce: challenge })

The DID is needed *before* the nonce, so it cannot come out of
`proxyLogin` — and both pages get it today from `vaultList()`, which
enumerates the user's whole vault to answer a question about one entry.
On a site with nothing bound that returns empty and the page gives up,
which is exactly what #144 was meant to stop and could not reach.

`walletProfile({ target })` answers that question and only that one. It
resolves the entry for the browser-attested origin, or raises the
first-use picker and binds the answer, and returns `{ did, entryId,
bound }`. It mints nothing and issues no session.

**`entryId` comes back deliberately.** The objection to a page holding
one was never possession; it was that *learning* one cost a
vault-enumerating prompt. An id handed back for the site's own entry
costs nothing, and passing it to `proxyLogin` saves the lookup this call
just did — so the two-call flow is the same number of VTA round trips as
the `vaultList` + `proxyLogin` it replaces, with one fewer prompt and no
disclosure of the rest of the vault.

**Two prompts on a first sign-in, one after.** Binding raises the
picker; the sign-in raises its own consent. Folding the second into the
first would mean a call that mints nothing silently pre-authorizing one
that does. First contact with a site is the place to ask twice.

An already-bound lookup does not prompt at all: it discloses one DID, to
the site that DID exists for, which is about to receive it inside an
id_token anyway. `principalDid` is read back from the VTA rather than
remembered, since it is maintainer-derived and an entry whose secret was
rotated there would otherwise report a DID it no longer signs as.

Two drift traps found on the way, both now guarded:

  - `provider.ts` inlined a second copy of the `BridgeMethod` union, so
    adding a method failed at its own call site with an error naming
    every method but the new one. It uses the type now.
  - `content.ts` cannot `import` (classic script), so it inlines the
    protocol constants under a "keep these in sync" comment with nothing
    enforcing it. Two invariants ride on that hand-sync: a method routed
    to a type absent from `PAGE_FACING_RUNTIME_TYPES` takes its origin
    from the page's own message body rather than the browser — and every
    vault entry, trust record and pin here is keyed on origin — while a
    mistyped constant routes to no handler and surfaces as a shapeless
    failure. `tests/page-facing-surface.test.mts` reads both sources and
    checks them against each other; its first assertions guard against
    the regexes silently matching nothing.

Requires the RP-side change in OpenVTC/verifiable-trust-infrastructure
and OpenVTC/affinidi-webvh-service; neither wallet call is removed, so
the repos can land in either order.

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

affinidi-appsecurity-bot commented Aug 30, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

3 findings need a human to review/validate.

Mandatory to check: 🔒 Security Code Review Report

Details

🛡️ Security Code Review Report — PR #144

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/first-use-persona-bindingmain
Validated 2026-09-05
Scan ID bb3595bb
Validator AI Security Validation Agent

🗺️ Scan Coverage

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

Module Files scanned Findings
packages/extension 6 5
packages/demo-rp 1 0

Executive Summary

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

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


🔒 Security Issues

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

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

🟡 Trusted-origin "remember" flag reused across RP DID targets when trusting an origin after first-use profile binding

Field Detail
Severity MEDIUM
Location packages/extension/src/background.ts:1900
Finding ID github_pr-6e35c2ff8224
CWE CWE-362
OWASP A04:2021-Insecure Design
Detection Source skill_scan

📝 Description:

After binding a first-use persona to an origin, the code calls trustOrigin() before confirming the dispatchProxyLogin() actually succeeds. If dispatch fails, the origin has already been marked trusted (and the vault entry already bound), so a subsequent call from the same origin will skip the chooseProfile consent (gatedConsent short-circuit) despite the first flow never having completed successfully end-to-end.

🌱 Root Cause: trustOrigin(req.origin, targetDid) is invoked immediately after bindProfileEntry succeeds and before the result of dispatchProxyLogin (the actual login) is checked, decoupling the 'trust this origin' side effect from successful completion of the operation it is meant to gate.

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

if (decision.remember) await trustOrigin(req.origin, targetDid);

const result = await dispatchProxyLogin({ ...req.params, entryId: bound.entryId });

💥 Impact:

Could cause the extension to treat an origin as trusted despite the underlying login not having succeeded, undermining the explicit consent model the code comments describe as security-critical.

Confidentiality: low · Integrity: medium · Availability: none

🎯 Attack Scenario:

A malicious/compromised page triggers a first-use proxyLogin, gets the operator to approve+remember, causing an entry to be bound and origin trusted, then the VTA-side dispatch fails (e.g., network blip or the RP rejecting the persona). On retry, gatedConsent short-circuits (trusted origin) and silently proceeds with a possibly stale/incorrect persona binding without the operator being asked to reconfirm, weakening the intended consent guarantee described in code comments.

🔧 Remediation:

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

Priority: Short-term

Only call trustOrigin() after confirming dispatchProxyLogin() succeeded (result.ok === true), so 'remember' semantics match a fully completed, successful first-use flow.

Secure code:

const result = await dispatchProxyLogin({ ...req.params, entryId: bound.entryId });
if (result.ok && decision.remember) await trustOrigin(req.origin, targetDid);
if (!result.ok) { /* existing error handling */ }
return result;

Also flagged at this location (same code, other weakness framings): CWE-841 — First-use persona binding uses remember consent to silently trust future persona/RP-DID combinations without re-confirmation

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 35%
  • AI Validation Evidence: EVIDENCE FOUND: description snippet shows if (decision.remember) await trustOrigin(req.origin, targetDid); const result = await dispatchProxyLogin(...) — trustOrigin() is called before confirming dispatchProxyLogin succeeded, which matches a plausible TOCTOU pattern (CWE-362) if trustOrigin persists trust regardless of subsequent dispatch failure. EVIDENCE NOT FOUND: Full background.ts is not provided so I cannot see whether there's compensating rollback logic (e.g., untrustOrigin on dispatch
  • 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.

🟠 First-use persona binding trusts consent-window-supplied DID without independent verification tie to origin

Field Detail
Severity HIGH
Location packages/extension/src/background.ts
Finding ID github_pr-19b661cf9b62
CWE CWE-346
OWASP A01:2021-Broken Access Control
Detection Source threat_model

📝 Description:

The first-use flow relies on the confirm popup returning selectedDid over the runtime messaging channel, and the background trusts this value to bind a persona to the origin. While bindProfileEntry does re-derive the signing key and validate the DID is hosted by the agent, the origin-binding trust boundary is effectively the extension's internal message channel between confirm.tsx and background.ts.

🌱 Root Cause: Consent decision (approved/remember/selectedDid) is communicated via chrome.runtime.sendMessage, and the background accepts selectedDid from that message without any cryptographic binding tying it to the specific consentId's rendered UI state beyond the pendingConsents map correlation.

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

const decision = await requestConsent({
  origin: req.origin,
  action: "Sign in via your VTA (proxied SIOP)",
  chooseProfile: true,
  ...(targetDid ? { rpDid: targetDid } : {}),
});
if (!decision.approved || !decision.selectedDid) {
  return { ok: false, error: "proxy-login denied by user" };
}
const bound = await bindProfileEntry(req.origin, decision.selectedDid, targetDid);

🎯 Attack Scenario:

If any other extension component or a compromised confirm.html context could send a RUNTIME_CONSENT_RESULT message with an attacker-chosen consentId and selectedDid, it could bind an arbitrary hosted DID to an origin without genuine user selection, since the settle function only checks that the DID exists in the agent (not that it reflects genuine UI interaction).

🔍 Validation Log

  • Verdict: ⚠️ Must-Review-By-Human
  • Confidence: 55%
  • AI Validation Evidence: EVIDENCE FOUND: background.ts flow (as described/quoted) shows requestConsent({origin, action, chooseProfile:true, ...targetDid}) then bindProfileEntry(req.origin, decision.selectedDid, targetDid); confirm.tsx sends selectedDid via decide(true, remember, undefined, selectedDid || undefined) which is populated only from RUNTIME_LIST_DIDS results (personas fetched via chrome.runtime.sendMessage), i.e. an operator pick from the agent's own hosted DID list, not arbitrary attacker input.
  • 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 #144

Field Value
Repository OpenVTC/vta-browser-plugin
Branch feat/first-use-persona-bindingmain
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

Implements a 'first-use persona binding' flow for proxied SIOP login: when a relying-party page calls proxyLogin({}) with no entryId, the wallet now resolves any existing vault entry bound to the browser-attested origin (strict equality match) or, on first visit, prompts the operator to choose which identity (persona) to use and binds that choice to the origin as a new vault entry after re-validating the DID and signing key server-side. This removes the prior requirement that a page/relying party name a specific vault entryId, eliminating a vault-enumerating consent prompt.

Diff: +231 / -19 lines
Types: feature, security

⚠️ Security Implications

🟠 Unauthenticated RUNTIME_CONSENT_RESULT listener can resolve pending consents with a forged approval/selectedDid

Unauthenticated RUNTIME_CONSENT_RESULT listener can resolve pending consents with a forged approval/selectedDid

Action: Validate sender.id === chrome.runtime.id and that sender.url matches the specific confirm.html window created for this consentId before invoking the pendingConsents resolver. Bind pendingConsents entries to the specific chrome.windows.create() window ID and add a short TTL.

🧩 Affected Components

Component Impact Change What Changed
Extension Background Service Worker (consent + vault orchestration) critical modified Added a full first-use identity-resolution and binding state machine (resolveProfileEntry → requestConsent(chooseProfile) → bindProfileEntry
Consent Popup UI (Confirm) high modified Added a first-use persona picker mode (chooseProfile) that fetches and displays the operator's hosted DIDs, requiring a selection before App
Bridge Protocol / Message Contracts medium modified ProxyLoginParams.entryId changed from required to optional; RuntimeConsentResult gains an optional selectedDid field.
Demo Relying Party Harness low modified Added a UI button and handler exercising the new no-entryId proxyLogin() call pattern.

📁 File Classifications

packages/extension/src/background.ts

  • Type: security

packages/extension/src/bridge-protocol.ts

  • Type: security

packages/extension/src/confirm.tsx

  • Type: security

packages/demo-rp/login-harness.mjs

  • Type: business-logic

🛡️ STRIDE Threat Model

Identified Threats (11)

🟠 STRIDE-1: Unvalidated Sender Spoofing in RUNTIME_CONSENT_RESULT Listener

Field Detail
Category Spoofing, Elevation of Privilege
Severity High
Likelihood Likely
CVSS 8.1 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-346,CWE-290
CAPEC CAPEC-98,CAPEC-141
OWASP A07:2021 - Identification and Authentication Failures

Description: RUNTIME_CONSENT_RESULT message listener in background.ts allows spoofing of consent decisions due to missing sender.id/sender.url verification, resulting in unauthorized persona binding or forged approvals

Evidence: packages/extension/src/background.ts:2430-2436

if ((message as { type?: string })?.type === RUNTIME_CONSENT_RESULT) {
    const { consentId, approved, remember, prfOutputB64u, selectedDid } =
      message as RuntimeConsentResult;
    pendingConsents.get(consentId)?.(approved, !!remember, prfOutputB64u, selectedDid);
    return false;
  }

Attack Scenario:

  1. Attacker identifies that chrome.runtime.onMessage.addListener in background.ts handles RUNTIME_CONSENT_RESULT without validating sender.id against chrome.runtime.id or sender.url against the expected confirm.html popup URL.
  2. A malicious extension installed in the same browser (or a compromised content script with access to chrome.runtime messaging if the extension's externally_connectable is misconfigured) sends a fabricated message: {type: RUNTIME_CONSENT_RESULT, consentId: , approved: true, remember: true, selectedDid: 'did:attacker:controlled'}.
  3. Background.ts's handler retrieves pendingConsents.get(consentId) and invokes the resolver with attacker-supplied approved/selectedDid values, as seen in pendingConsents.get(consentId)?.(approved, !!remember, prfOutputB64u, selectedDid).
  4. If the consentId (a crypto.randomUUID()) is guessed, intercepted via another channel, or a race condition allows replay before the legitimate popup responds, the pending Promise in requestConsent resolves with the attacker's approved/selectedDid values.
  5. Because bindProfileEntry re-validates the DID against the agent's own hosted-DID list, the attacker cannot bind an arbitrary external DID, but CAN force approval=true for the operator's own legitimate personas, silently binding an origin-persona pair the operator never consented to, or force-approve a step-up/task consent for the requesting origin.

Preconditions: Attacker has code execution in the browser's extension context (e.g., another malicious extension) or can guess/leak the crypto.randomUUID() consentId., The listener does not check sender.id or restrict message origin to the confirm.html popup context.

Existing Controls: consentId is a cryptographically random UUID reducing guessability. • bindProfileEntry re-validates the selectedDid against the agent's actual hosted DID list before binding, preventing binding of a wholly fabricated DID.

Recommended Mitigations: Validate sender.id === chrome.runtime.id and sender.url matches the expected confirm.html popup URL with the exact consentId in every RUNTIME_CONSENT_RESULT handler invocation. • Bind pendingConsents entries to the specific chrome.windows.create() window ID and reject messages from any other sender context. • Add a single-use, short-TTL expiration to pendingConsents entries to shrink the replay window.


🟡 STRIDE-2: Persona Enumeration via Repeated proxyLogin Origin Prefix Filtering

Field Detail
Category Information Disclosure
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-203,CWE-200
CAPEC CAPEC-116,CAPEC-497
OWASP A01:2021 - Broken Access Control

Description: window.vtaWallet.proxyLogin() entry point in resolveProfileEntry allows partial vault information disclosure due to reliance on handleVaultList's targetOriginPrefix filter before local exact-match narrowing, resulting in potential timing/behavior-based enumeration of vault contents

Evidence: packages/extension/src/background.ts:1900-1917

const listed = await handleVaultList({
    type: RUNTIME_VAULT_LIST,
    filter: { secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin },
  });
  if (!listed.ok) return { ok: false, error: listed.error };
  const match = matchProfileEntry(listed.result.entries, req.origin);

Attack Scenario:

  1. A malicious page at https://example.com.evil.test calls window.vtaWallet.proxyLogin({}).
  2. resolveProfileEntry in background.ts invokes handleVaultList with filter: {secretKind: PROFILE_SECRET_KIND, targetOriginPrefix: req.origin}, per code comment intentionally described as a coarse, non-authoritative prefix filter.
  3. handleVaultList returns a candidate set of entries whose targetOriginPrefix loosely matches 'https://example.com.evil.test' — if the implementation of handleVaultList's prefix filtering is broader than expected (e.g., simple string startsWith without a boundary check on the underlying store), entries for 'https://example.com' could theoretically be included in the pre-filter result set even though matchProfileEntry's final === check discards them.
  4. If any side channel exists (timing differences, response size differences, or a bug in matchProfileEntry) between 'zero candidates returned by handleVaultList' and 'one or more candidates returned but all rejected by ===', an attacker page could infer whether entries exist for a similar-looking legitimate origin.
  5. Repeated proxyLogin({}) calls from crafted origins that are prefixes/near-matches of high-value RPs could probabilistically enumerate which origins the operator has personas bound to, without needing the entryId itself.

Preconditions: Attacker controls or can register a domain that is a lexical prefix-match superset of a legitimate origin the operator uses (e.g. example.com.attacker.test)., handleVaultList's targetOriginPrefix filtering logic is broader than a safe prefix check, or observable timing/response differences exist between filter outcomes.

Existing Controls: matchProfileEntry performs the authoritative match using strict === on the attested origin, not the coarse prefix. • The origin passed to handleVaultList as req.origin is browser-attested (from the content script / extension messaging layer), not page-supplied.

Recommended Mitigations: Ensure handleVaultList's targetOriginPrefix filter and any underlying storage query use exact-origin or true-prefix-with-boundary matching consistent with matchProfileEntry's semantics. • Normalize response timing/shape so that 'no match after filtering' and 'no candidates found' are indistinguishable to the calling code path. • Rate-limit proxyLogin calls per calling origin to blunt automated enumeration attempts.


🟡 STRIDE-3: Consent Bypass via Approval Without selectedDid Race Condition

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

Description: requestConsent's settle() callback in background.ts allows a Time-of-Check/Time-of-Use race due to the settled flag guarding only against multiple resolutions rather than validating message ordering, resulting in a possible malformed or stale decision being accepted as the final consent outcome

Evidence: packages/extension/src/background.ts:694-706

let settled = false;
    const settle = (approved: boolean, remember: boolean, selectedDid?: string) => {
      if (settled) return;
      settled = true;
      pendingConsents.delete(consentId);
      resolve({ approved, remember, ...(selectedDid ? { selectedDid } : {}) });
    };

Attack Scenario:

  1. The confirm.tsx popup's persona picker UI sends a RUNTIME_CONSENT_RESULT message via chrome.runtime.sendMessage when the operator clicks Approve/Deny.
  2. background.ts's settle(approved, remember, selectedDid) function checks if (settled) return; settled = true; — a synchronous guard against double-invocation from the SAME resolver reference.
  3. However, if the popup window is programmatically manipulated (e.g., via a second, malformed postMessage-like invocation triggered by a slow-loading persona list race in confirm.tsx's useEffect, or a re-render that fires decide() twice before window.close() completes) to send two RUNTIME_CONSENT_RESULT messages for the same consentId with different selectedDid values before the window fully closes, only the first is honored — but the ordering between the async chrome.windows.create callback registering pendingConsents.set and a fast, automated/scripted popup response is not strictly serialized against window-focus or user-input events.
  4. Because chrome.windows.onRemoved is likely used as a fallback denial (implied by 'or is closed, which counts as a denial') racing against a message-based settle, a crafted timing where the window-closed event fires concurrently with a delayed legitimate message could theoretically resolve the promise with an unintended default (denial) after the user believed they approved, or vice versa if the fallback treats an ambiguous state as approved.
  5. This desynchronization between UI intent and background-resolved state could result in either a spurious denial (DoS-lite/UX issue) or, in a worse implementation gap, an approval proceeding without a genuinely user-confirmed selectedDid, which the current code most defends against by requiring decision.approved && decision.selectedDid jointly.

Preconditions: Popup UI (confirm.tsx) has a code path allowing decide() to be invoked more than once with different arguments before window.close() takes effect., Attacker requires the ability to trigger rapid re-renders or duplicate event bindings in the popup, which is more plausible as a bug-class than a direct external attack.

Existing Controls: settled boolean flag prevents the Promise from resolving more than once. • Background.ts requires both decision.approved AND decision.selectedDid to be truthy before proceeding to bindProfileEntry, closing off the most dangerous half-approved state. • window.close() is called immediately after sendMessage in decide(), minimizing the window for a second invocation.

Recommended Mitigations: Disable all Approve/Deny UI controls immediately upon first click in confirm.tsx to prevent duplicate decide() invocations. • Add a client-side settled guard in confirm.tsx mirroring the background.ts guard, so at most one RUNTIME_CONSENT_RESULT message is ever sent per consentId. • Log and alert on any RUNTIME_CONSENT_RESULT received for an already-settled or unknown consentId as a possible tampering indicator.


🔵 STRIDE-4: Trusted-Origin Short-Circuit Misuse Preventing Repudiation Tracking for Persona Binding

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

Description: handleVaultProxyLoginPage in background.ts intentionally avoids gatedConsent for first-use persona binding due to design-level nuance, resulting in a risk that future refactors could silently reintroduce the trusted-origin bypass and remove the audit trail this PR establishes

Evidence: packages/extension/src/background.ts:1846-1873

const decision = await requestConsent({
    origin: req.origin,
    action: "Sign in via your VTA (proxied SIOP)",
    chooseProfile: true,
    ...(targetDid ? { rpDid: targetDid } : {}),
  });

Attack Scenario:

  1. The PR explicitly documents that gatedConsent (which short-circuits via a remembered 'trust this origin' decision) is deliberately NOT used for the first-use persona-binding branch, only requestConsent is used, per the inline comment reasoning that a previous 'remember this site' click for login cannot be reinterpreted as consent to bind a NEW identity.
  2. This is currently correctly implemented — resolved.entryId present routes to gatedConsent, while the first-use (!resolved.entryId) path routes to requestConsent with chooseProfile: true unconditionally.
  3. However, no automated test or runtime assertion enforces that a future code change (e.g., a well-intentioned refactor merging the two branches for code simplification) cannot accidentally swap requestConsent for gatedConsent in the persona-binding path, silently reintroducing a trusted-origin bypass for the highest-consequence action in this flow (irreversibly binding a persona to an origin).
  4. If that regression occurred, an origin the operator had merely trusted for prior logins could bind a NEW persona to itself without ANY consent prompt or user-visible log entry, and there is no logging/telemetry evident in the reviewed code that records WHICH consent path (gated vs full) was taken for a given bind, hampering forensic reconstruction of why/how a binding occurred.
  5. The absence of a persisted, tamper-evident audit log entry recording 'binding X, via full consent, at time T' means an operator disputing a binding they didn't knowingly perform has no verifiable evidence trail beyond the vault entry itself.

Preconditions: A future code change accidentally unifies or misroutes the gated/full consent branches., No structured audit logging exists for consent-path decisions.

Existing Controls: Current code correctly enforces requestConsent (non-short-circuited) for the chooseProfile=true path, as documented extensively in code comments. • The extensive inline rationale comments serve as a form of self-documenting regression guard for reviewers.

Recommended Mitigations: Add a unit/integration test asserting that the first-use persona-binding branch NEVER calls gatedConsent, failing the build if violated. • Implement structured, tamper-evident audit logging for every persona bind event recording origin, selectedDid, consent path used (gated vs full), and timestamp. • Add a lint rule or code review checklist item flagging any diff that touches the resolved.entryId branching logic in handleVaultProxyLoginPage.


🔵 STRIDE-5: Signing Key Ambiguity Information Disclosure via bindProfileEntry Error Messages

Field Detail
Category Information Disclosure
Severity Low
Likelihood Unlikely
CVSS 2.3 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-209
CAPEC CAPEC-118
OWASP A09:2021 - Security Logging and Monitoring Failures

Description: bindProfileEntry in background.ts allows disclosure of internal key-material topology to the calling page via the error string returned when derived signing-key candidate counts are not equal to one, resulting in leakage of the number of signing keys associated with a DID

Evidence: packages/extension/src/background.ts:1957-1966

error:
        candidates.length === 0
          ? `no signing key could be derived from ${did}`
          : `${did} has ${candidates.length} possible signing keys — bind it from the wallet's vault panel, which lets you choose one`,

Attack Scenario:

  1. A relying-party page calls window.vtaWallet.proxyLogin({}) triggering the first-use flow, and the operator selects a persona DID that happens to have multiple (or zero) derivable signing keys.
  2. bindProfileEntry's candidates.length !== 1 branch constructs an error message: ${did} has ${candidates.length} possible signing keys — bind it from the wallet's vault panel....
  3. This error propagates up through handleVaultProxyLoginPage's bound.error back to the calling page's proxyLogin() promise rejection (per the demo harness's catch (e) { show('proxyLogin rejected', ...) } pattern), meaning the calling web page receives and can log/exfiltrate the exact count of signing keys tied to the operator's chosen DID.
  4. While the DID itself and key count are not typically secret in DID-based systems, the precise count can be a fingerprinting signal (correlating the operator's wallet configuration across sessions/sites) or aid an attacker in narrowing which DID management pattern (single-key vs multi-key custody) the operator uses, informing further targeted social engineering or credential-recovery attacks.
  5. No sanitization step exists between the internal error object and what's surfaced to the untrusted calling page in the current code path shown.

Preconditions: Operator selects a DID during first-use persona picker that maps to zero or multiple signing keys., The calling web page inspects the rejected promise's error message content, which is standard JS behavior and requires no special access.

Existing Controls: The information disclosed (key count) is low-sensitivity relative to actual key material or DIDs, which are inherently semi-public in DID ecosystems. • This error only fires on an edge case (ambiguous key derivation), not the common path.

Recommended Mitigations: Return a generic, page-facing error (e.g. 'sign-in configuration required — open your wallet') and log the detailed candidate-count diagnostic only to the extension's internal/offscreen console, never to the page-facing promise rejection. • Audit all error paths in handleVaultProxyLoginPage and bindProfileEntry for similar detail leakage before returning to req (the page-facing response).


🔵 STRIDE-6: RP DID Disclosure in Failed First-Sign-In Error Message

Field Detail
Category Information Disclosure
Severity Low
Likelihood Possible
CVSS 2.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-209
CAPEC CAPEC-118
OWASP A01:2021 - Broken Access Control

Description: dispatchProxyLogin failure handling in handleVaultProxyLoginPage allows disclosure of the operator-selected DID string back to the calling relying-party origin due to embedding decision.selectedDid directly into the page-facing error text, resulting in confirmation of the operator's chosen persona even on a rejected first sign-in

Evidence: packages/extension/src/background.ts:1878-1887

return {
      ok: false,
      error:
        `${result.error} — this was the first sign-in as ${decision.selectedDid}. ` +
        `If ${req.origin} refused it, that identity needs to be on the site's access list.`,
    };

Attack Scenario:

  1. An attacker-controlled or compromised relying-party site invokes proxyLogin({}) triggering the first-use persona flow; the operator picks a persona DID and consents.
  2. dispatchProxyLogin sends the request to the VTA/offscreen document, and if the RP's actual backend rejects the resulting SIOP token (e.g., because the DID isn't on the RP's access list), result.ok is false.
  3. handleVaultProxyLoginPage constructs the page-facing error: ${result.error} — this was the first sign-in as ${decision.selectedDid}. If ${req.origin} refused it, that identity needs to be on the site's access list.
  4. This string, containing the FULL selectedDid, is returned to the page via the rejected promise, meaning the calling JavaScript on the page (which may itself be malicious or compromised via XSS) now has definitive knowledge of the exact DID the operator attempted to authenticate with — even though the RP backend itself rejected the login and normally would not have received/logged the DID in a successful correlated session.
  5. This is a deliberate UX tradeoff (per code comments: 'this is where the operator finds out it did') but it is also a case where the wallet, not the RP backend, is the source of the DID disclosure to page-level JavaScript, which is a stronger threat model than trusting only the RP's own logging.

Preconditions: The RP's backend rejects the first proxied SIOP login attempt., The calling page's JavaScript context is malicious, compromised (XSS), or shared with a third party (e.g., via a malicious ad/iframe with postMessage access to the parent's error handling).

Existing Controls: This appears to be an intentional design tradeoff prioritizing operator troubleshooting UX over strict DID confidentiality. • The vault entry itself is retained rather than deleted, limiting repeated disclosure on retries (subsequent attempts route through the gatedConsent/entryId branch, not this error path).

Recommended Mitigations: Consider surfacing the full DID only within the extension's own UI (e.g., a wallet notification/badge) rather than embedding it in the promise rejection visible to page-level JavaScript. • If page-facing disclosure is retained for UX reasons, document it explicitly as an accepted risk and ensure it is not extended to also disclose signing key identifiers or vault entry IDs in the same error path.


🟡 STRIDE-7: Popup Window Bounds Manipulation Enabling Clickjacking-Style Approve Button Obscuring

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:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-1021,CWE-451
CAPEC CAPEC-103
OWASP A04:2021 - Insecure Design

Description: consentWindowBounds sizing logic in requestConsent allows insufficient popup height for the combined persona-picker plus reason-card UI due to only three discrete height tiers, resulting in a risk that Approve/Deny controls render off-screen or require scrolling and could be approved without the operator reading full context

Evidence: packages/extension/src/background.ts:690-692

const bounds = await consentWindowBounds(args.reason ? 660 : args.chooseProfile ? 680 : 560);

Attack Scenario:

  1. An attacker-controlled relying party triggers a first-use proxy login AND supplies a step-up reason simultaneously is not directly possible per this code (reason and chooseProfile bounds are mutually exclusive: args.reason ? 660 : args.chooseProfile ? 680 : 560), but if the persona list returned by RUNTIME_LIST_DIDS is large (attacker cannot control the count of the operator's own personas, but a compromised/malicious identity provisioning flow elsewhere in the extension could inflate it), the fixed 680px height budget in consentWindowBounds may not accommodate the rendered <select>/list plus the changedFromRpDid warning banner plus the Approve/Deny buttons.
  2. If the operator's screen resolution or OS-level display scaling is small, or if the confirm.tsx render includes both a changedFromRpDid warning AND the persona picker (both can coexist per the code, since the chooseProfile ? 680 branch doesn't add extra height for a simultaneous changedFrom warning), the combined content could overflow the fixed-height popup.
  3. On some OS/browser combinations, extension popup windows opened via chrome.windows.create with type: 'popup' cannot be resized or scrolled reliably by the operator without deliberate effort, meaning the Approve button could render at a Y-offset requiring the operator to scroll — and the code's own comment acknowledges this exact risk ('an Approve the operator has to scroll to find is one they approve without reading what is above it').
  4. An attacker who can influence WHICH warnings/content render simultaneously (e.g., by triggering a changedFromRpDid warning via causing prior origin trust churn, combined with being the first RP to trigger a persona pick for a legacy/migrated wallet with many personas) increases the likelihood of overflow, nudging the operator toward blind-approving via muscle memory or impatience.
  5. This is a design/UX security control gap rather than a direct code injection, but it directly weakens the human-in-the-loop consent guarantee the whole flow is built around.

Preconditions: Operator has many hosted DIDs/personas (list length large enough to overflow 680px) or a simultaneous changedFromRpDid warning renders alongside the persona picker., Operator's browser/OS does not comfortably resize or scroll the popup window.

Existing Controls: consentWindowBounds already scales height based on reason/chooseProfile flags, showing developer awareness of this exact risk class. • The persona list auto-preselects when exactly one DID exists, reducing the common case's rendered height.

Recommended Mitigations: Make the persona-picker list scrollable within a fixed-height container rather than letting the whole popup height vary unboundedly with candidate count. • Add an automated visual-regression/UI test asserting Approve/Deny buttons remain within the viewport for the maximum realistic persona count and combined-warning scenarios. • Dynamically compute popup height based on actual rendered content height up to a maximum, falling back to an internal scrollbar beyond that maximum.


🟡 STRIDE-8: Cross-Context DID Confusion via Missing rpDid Binding Validation in trustOrigin

Field Detail
Category Tampering, Spoofing
Severity Medium
Likelihood Possible
CVSS 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Medium
CWE CWE-345,CWE-346
CAPEC CAPEC-459,CAPEC-151
OWASP A08:2021 - Software and Data Integrity Failures

Description: handleVaultProxyLoginPage's call to trustOrigin(req.origin, targetDid) on decision.remember allows an origin-to-RP-DID trust binding to be recorded using a page-supplied targetDid value without independent verification, resulting in a risk that a malicious page could poison the remembered RP-DID association for its own origin to facilitate a later downgrade or confusion attack

Evidence: packages/extension/src/background.ts:1836-1838,1865

const target = req.params.target as { kind?: string; did?: string } | undefined;
  const targetDid = target?.kind === "did" ? target.did : undefined;
  ...
  if (decision.remember) await trustOrigin(req.origin, targetDid);

Attack Scenario:

  1. A page at a legitimate-looking but attacker-registered origin (e.g., a typosquat or a compromised subdomain) calls proxyLogin({target: {kind: 'did', did: 'did:attacker-preferred:target'}}).
  2. targetDid is extracted directly from req.params.target as target?.kind === 'did' ? target.did : undefined — this is page-supplied, unvalidated attacker input, per the code comment 'so require explicit consent naming the requesting origin + target RP', implying the consent screen displays it but does not independently verify it resolves to anything real.
  3. If the operator approves the first-use flow and ticks 'remember', trustOrigin(req.origin, targetDid) persists the page-supplied targetDid as the trusted RP-DID association for this origin, without the background re-deriving or validating that targetDid actually corresponds to a legitimate, resolvable RP DID document (unlike bindProfileEntry, which DOES re-validate the operator's own selectedDid against handleListDids).
  4. On a subsequent visit, other code paths (referenced elsewhere as 'M5: when set, the rpDid this origin previously used... render a louder warning so operator sees the swap') compare a NEW targetDid against this stored value to detect RP-DID swaps — but if the ORIGINAL stored value was already attacker-poisoned, the swap-detection mechanism's baseline is corrupted from the start, and a real, later swap TO the legitimate RP DID would incorrectly trigger the 'changed from' warning, or worse, an attacker could set the initial trusted value to be permissive/malleable such that a later actual attacker RP DID doesn't trigger the warning at all.
  5. This undermines the M5 RP-DID-swap detection control's integrity at its root, since it trusts unauthenticated, page-supplied data as its initial baseline of truth.

Preconditions: Operator approves first-use consent with 'remember' ticked for an attacker-influenced or typosquatted origin., The RP-DID swap-detection feature (M5) relies on the initially stored value as ground truth without independent verification at bind-time.

Existing Controls: The consent prompt does display the targetDid to the operator for review before approval (implied by 'require explicit consent naming the requesting origin + target RP'), giving a human review opportunity. • bindProfileEntry independently re-validates the operator's own selectedDid, limiting the blast radius to the RP-DID association metadata rather than the operator's own signing identity.

Recommended Mitigations: Before persisting via trustOrigin, resolve and verify that targetDid corresponds to a real, resolvable DID document consistent with the origin (e.g., via DID-to-origin binding/well-known verification) rather than trusting the page-supplied string as-is. • Display a clear, unambiguous confirmation of the exact RP DID string being remembered, separate from the general consent action text, so the operator's review is specifically directed at this value. • Add integrity protection (e.g., a signed or hash-chained history) to the origin-to-RP-DID trust store so tampering or poisoning attempts are detectable.


🔵 STRIDE-9: Denial of Service via Unbounded proxyLogin First-Use Prompt Spam

Field Detail
Category Denial of Service
Severity Low
Likelihood Likely
CVSS 3.8 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:N/SA:L
Residual Severity Low
CWE CWE-799,CWE-400
CAPEC CAPEC-125
OWASP A04:2021 - Insecure Design

Description: window.vtaWallet.proxyLogin({}) entry point allows repeated triggering of the first-use consent popup due to absence of rate-limiting or debounce on requestConsent invocation from a single origin, resulting in operator annoyance/consent-fatigue-driven denial of service against the wallet's usability

Evidence: packages/extension/src/background.ts:1841-1873

const decision = await requestConsent({
    origin: req.origin,
    action: "Sign in via your VTA (proxied SIOP)",
    chooseProfile: true,
    ...(targetDid ? { rpDid: targetDid } : {}),
  });

Attack Scenario:

  1. A malicious or buggy page calls window.vtaWallet.proxyLogin({}) in a tight loop or on every user interaction (click, scroll, mousemove) before the operator has had a chance to respond to or the entry has been bound.
  2. Each call reaches handleVaultProxyLoginPage → resolveProfileEntry (no entryId yet, since binding hasn't completed) → requestConsent({chooseProfile: true, ...}), which opens a NEW chrome.windows.create popup via chrome.windows.create({url, type: 'popup', ...bounds}) for every call, since no debounce/in-flight-request guard is evident in the provided code.
  3. The operator's screen is flooded with multiple simultaneous first-use persona-picker popups, degrading usability and potentially causing consent fatigue where the operator approves one without careful review just to make the flood stop.
  4. Because pendingConsents is keyed by a fresh crypto.randomUUID() per call, there's no natural deduplication — N calls produce N independent pending consent promises and N popup windows, exhausting screen real estate and browser window-management resources.
  5. While this cannot directly bind a malicious DID (bindProfileEntry re-validates), it can degrade the operator's ability to make informed decisions and may cause them to approve a binding under duress/frustration, or crash/freeze the browser's window manager under extreme repetition counts.

Preconditions: Calling page can invoke window.vtaWallet.proxyLogin({}) repeatedly without any extension-side rate limit., No existing pendingConsents deduplication keyed by (origin, action-type) exists in the reviewed code.

Existing Controls: Each consent window requires an explicit user action (Approve/Deny) to resolve, so no binding occurs purely from popup volume. • window close (implied) counts as an automatic denial, self-cleaning abandoned prompts eventually.

Recommended Mitigations: Add a per-origin, per-action-type debounce/coalesce so multiple concurrent proxyLogin calls from the same origin reuse a single pending consent prompt instead of spawning new ones. • Implement a rate limit (e.g., max N consent prompts per origin per minute) with a cooldown/backoff error returned to the page on excess. • Focus/raise an existing pending popup for the same origin+action instead of creating a new window.


🟡 STRIDE-10: Prompt Injection via Attacker-Controlled action/reason Fields Rendered in Confirm Popup

Field Detail
Category Tampering, Spoofing
Severity Medium
Likelihood Possible
CVSS 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N
Residual Severity Low
CWE CWE-451,CWE-1021
CAPEC CAPEC-163,CAPEC-98
OWASP A04:2021 - Insecure Design

Description: requestConsent's URL construction in background.ts allows page-influenced text to be encoded into the confirm.html query string via the action and reason parameters, resulting in a risk of social-engineering-style consent UI manipulation if any of these values are ever sourced from page-controlled input rather than fixed background-authored strings

Evidence: packages/extension/src/background.ts:684-692

(args.action ? `&action=${encodeURIComponent(args.action)}` : "") +
    (args.noRemember ? `&noRemember=1` : "") +
    (args.stepUp ? `&stepUp=1` : "") +
    (args.chooseProfile ? `&chooseProfile=1` : "") +
    (args.reason ? `&reason=${encodeURIComponent(args.reason)}` : "")

Attack Scenario:

  1. requestConsent constructs the confirm.html popup URL by string-concatenating (args.action ? &action=${encodeURIComponent(args.action)} : '') and similarly for args.reason.
  2. In the reviewed proxy-login flow, action is a fixed, hardcoded string ('Sign in via your VTA (proxied SIOP)') authored by the extension itself — good practice — but the function signature accepts an arbitrary action?: string and reason?: string from ANY caller within background.ts, and the code comment for reason explicitly warns: 'never pass a page-supplied string here', implying this has been identified internally as a real risk for OTHER call sites of requestConsent/gatedConsent not shown in this diff.
  3. If any other current or future call site in background.ts (outside this reviewed diff) constructs reason or action from a page-supplied or RP-supplied value (e.g., an OIDC error description, a DIDComm message field, or a query parameter echoed from the RP), that string would flow encoded into the confirm.html URL and be rendered in the consent popup as an operator-facing 'reason' — enabling a classic consent-prompt social-engineering attack where the attacker crafts misleading text (e.g., 'Security check: re-verify your identity to prevent account suspension') to manipulate the Approve decision.
  4. Even though confirm.tsx reportedly renders such values 'as plain text, never markup' (per code comment), plain-text social engineering (phishing-style wording) is not mitigated by output encoding — encodeURIComponent/XSS-safe rendering prevents injection but not semantic deception.
  5. This threat is CURRENTLY not exploited in the reviewed diff (action is hardcoded here), but the shared, permissive requestConsent function signature is the enabling weakness that has apparently already required an explicit internal warning comment, indicating elevated risk of reintroduction at other call sites.

Preconditions: A current or future call site passes RP/page-influenced text into requestConsent's action or reason parameters., Operator does not scrutinize the semantic content of the consent prompt text critically.

Existing Controls: Explicit code comment warning against ever passing a page-supplied string as reason. • confirm.tsx renders these values as plain text (not HTML/markup), preventing script injection even if the content were attacker-controlled. • In the reviewed diff, action is a fixed literal string for the proxy-login flow specifically.

Recommended Mitigations: Refactor requestConsent/gatedConsent to accept only an enum/identifier for action type, with all display text sourced from a background-authored lookup table, eliminating free-text parameters entirely. • Add a lint rule or static-analysis check flagging any call to requestConsent/gatedConsent where action or reason is derived from a variable traceable to req.params or any page/RP-sourced object. • Add a runtime assertion in requestConsent that action/reason match an allow-list of known-safe strings.


🔵 STRIDE-11: Repudiation of Persona Binding Decision Due to Missing Persistent Consent Audit Trail

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

Description: bindProfileEntry's vault-write operation in background.ts allows an operator to later deny having chosen a specific persona for an origin due to absence of any visible immutable audit log entry for the binding event, resulting in a repudiation risk during dispute resolution or incident response

Evidence: packages/extension/src/background.ts:1964-1972

const upserted = await handleVaultUpsert({
    type: RUNTIME_VAULT_UPSERT,
    ...buildProfileEntry({
      origin,
      did,
      contextId: record.contextId,
      signingKeyId: candidates[0]!,
      ...(rpDid ? { rpDid } : {}),
    }),
  });

Attack Scenario:

  1. An operator completes the first-use flow, selecting a persona DID via the confirm.tsx picker, which is bound to the requesting origin via bindProfileEntry → handleVaultUpsert.
  2. No code in the reviewed diff writes a separate, tamper-evident audit log entry (e.g., to a write-once log store) recording WHO (which consent session/consentId) approved WHICH binding (origin → did) at WHAT time — the only persisted artifact is the vault entry itself, which represents the CURRENT state, not the historical decision event.
  3. If a dispute arises later (e.g., the operator claims 'I never chose to sign into this site as this identity, the extension must have a bug'), there is no independent, immutable evidence trail to confirm or refute that a genuine, user-approved consentId with a matching selectedDid actually produced this binding — only the vault entry's existence, which does not itself prove operator intent versus a hypothetical background bug or race condition (see STRIDE-3).
  4. This gap is most consequential in a shared-computer or multi-user scenario, or if the wallet is later suspected of having been compromised (e.g., via STRIDE-1's sender-spoofing vector) — forensic reconstruction of 'was this binding user-approved or attacker-injected' is not possible from the artifacts the code persists.
  5. This compounds the severity of STRIDE-1 and STRIDE-3: even if those threats are exploited, the absence of an audit trail here means the exploitation may never be detected or provable after the fact.

Preconditions: A dispute or forensic investigation into a specific origin-persona binding arises., No external logging/SIEM integration captures the consent decision independently of the extension's own runtime state.

Existing Controls: The vault entry itself has an id and presumably a creation context, providing partial (non-authoritative) evidence of when a binding was created. • Extensive inline code comments document the intended security rationale, aiding future security review even without runtime logging.

Recommended Mitigations: Persist a structured, append-only audit log entry for every consent decision (approved/denied, action type, origin, selectedDid if applicable, timestamp, consent path used) independent of the vault entry itself. • Expose this audit log to the operator via a wallet 'activity history' UI so they can self-verify past binding decisions. • Consider cryptographically chaining audit log entries (hash-linking) to detect post-hoc tampering with the log itself.



🍝 PASTA Threat Model

Application Purpose

A browser extension identity wallet that lets operators authenticate to relying-party web sites using self-issued OpenID/DIDComm identities (SIOP) without exposing raw vault contents, providing per-site persona isolation and explicit human-in-the-loop consent for every identity disclosure or binding.

Inherent Risks

  • The extension mediates a highly sensitive trust boundary between untrusted web pages and cryptographic identity material, making any consent-flow logic error high-impact by design.
  • Chrome extension message-passing (chrome.runtime.onMessage) is a shared, same-browser channel that other installed extensions can potentially reach if sender validation is incomplete.
  • Human-in-the-loop consent UIs are inherently vulnerable to consent fatigue, UI-overflow, and social-engineering wording regardless of the underlying cryptographic soundness.

Objectives

Risk: Treat any code path allowing unattended or spoofed consent-result messages as high severity.; Treat information disclosure of vault contents/entry counts to page-level JavaScript as elevated risk even when individually low-impact.
Business: Enable frictionless, privacy-preserving per-site identity sign-in without a centralized identity provider.; Differentiate the wallet via strong, auditable user-consent guarantees for any identity-binding action.
Security: Guarantee that no persona binding occurs without an explicit, reviewable, single-use operator approval.; Guarantee that a page can never name/coerce a specific DID to be bound on its behalf.; Guarantee that origin matching used for trust decisions is exact, not prefix-based.
Financial: Avoid liability/breach costs associated with unauthorized identity binding or credential leakage.; Minimize support costs from consent-flow usability failures.
Compliance: Align with WebAuthn/FIDO-style explicit user presence and intent principles for credential/identity operations.; Support auditability requirements relevant to identity-assurance frameworks (e.g., eIDAS-adjacent DID wallets).
Functional: Support proxied SIOP login for relying parties with zero prior vault knowledge (first-use flow).; Support per-origin persona resolution using browser-attested origin rather than page-supplied claims.
Operational: Ensure the consent popup renders reliably across screen sizes and persona-list lengths.; Ensure background service worker message handling is resilient to malformed or replayed messages.

Business Impact Analysis (3)

BIA-1: Proxied SIOP First-Use Login Flow (Critical)

End-to-end process by which a relying-party page requests a proxied SIOP login, the wallet resolves or establishes a persona binding for that origin via operator consent, and returns a signed identity assertion.

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

  • Stakeholders: Extension Development Team / Operators (Wallet Users) / Relying-Party Site Operators / Security/Compliance Reviewers
  • Dependencies: Background Service Worker (background.ts) / Confirm Popup UI (confirm.tsx) / Offscreen Document (DIDComm/holder identity pipeline) / Vault Storage (RUNTIME_VAULT_UPSERT / handleVaultList) / VTA REST/DIDComm Connection
  • Disruptions: Sender-spoofed RUNTIME_CONSENT_RESULT forging approval (STRIDE-1) / Popup UI overflow causing blind-approval (STRIDE-7) / Malicious origin poisoning the RP-DID trust baseline (STRIDE-8) / Prompt-spam degrading operator decision quality (STRIDE-9)
  • Impacts: Unauthorized persona-to-origin binding without genuine consent / Operator confusion/mistrust leading to abandonment of the wallet / Forensic inability to prove or disprove a disputed binding decision / RP-DID swap detection baseline corruption undermining a security feature (M5)

BIA-2: Vault Entry Resolution and Enumeration Protection (High)

Process by which the wallet resolves which vault entry (if any) applies to a requesting origin using browser-attested origin matching, without allowing the page to enumerate unrelated vault contents.

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

  • Stakeholders: Operators (Wallet Users) / Extension Development Team
  • Dependencies: handleVaultList / matchProfileEntry / first-use-profile.ts / Vault Storage
  • Disruptions: Prefix-filter based enumeration of near-match origins (STRIDE-2)
  • Impacts: Partial disclosure of which origins the operator has personas bound to / Erosion of the privacy guarantee that a site 'never learns what else is in the vault'

BIA-3: Consent Decision Integrity and Auditability (Medium)

Process ensuring every approve/deny decision rendered by the confirm popup is delivered exactly once, from a verified sender, and is durably recorded for later dispute resolution.

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

  • Stakeholders: Operators (Wallet Users) / Security/Compliance Reviewers
  • Dependencies: chrome.runtime messaging / pendingConsents map / (absent) audit log store
  • Disruptions: Missing sender verification (STRIDE-1) / Duplicate-decide race condition (STRIDE-3) / Missing audit trail (STRIDE-11)
  • Impacts: Inability to prove operator intent after the fact / Increased attacker dwell-time if spoofed approvals go undetected

Technical Scope

Roles (2): RO-1 Operator · RO-2 Relying Party (Untrusted)

Actors (3): AC-1 Wallet Operator · AC-2 Relying-Party Page Script · AC-3 Background Service Worker Process

Entry Points (4): EP-1 Page-Facing proxyLogin API · EP-2 Vault Proxy Login Runtime Message · EP-3 Consent Result Runtime Message · EP-4 List DIDs for Persona Picker

Threat Actors (3): TA-1 Malicious Relying Party · TA-2 Co-Installed Malicious Extension · TA-3 Curious/Impatient Operator (Non-Adversarial)

Infrastructure (1): IF-1 Browser Extension Runtime

Trust Boundaries (4): TB-1 Untrusted Web Page Boundary · TB-2 Extension Privileged Runtime Boundary · TB-3 Consent Popup UI Boundary · TB-4 Offscreen Identity/DIDComm Boundary

External Entities (2): EE-1 Relying Party Backend · EE-2 VTA (Verifiable Trust Agent)

System Components (6): SC-1 Relying-Party Web Page · SC-2 Background Service Worker · SC-3 Confirm Popup UI · SC-4 Vault Storage · SC-5 Offscreen Document / Holder Identity Pipeline · SC-6 Demo RP Login Harness

Resources And Assets (3): RA-1 Vault Entries (Origin-to-Persona Bindings) · RA-2 Hosted DID Records / Signing Keys · RA-3 Pending Consent State

Technologies And Dependencies (3): TD-1 Chrome Extension Manifest V3 APIs · TD-2 React · TD-3 DID/SIOP Protocol Stack

Use Cases (2)

  • First-Use Proxied SIOP Login: An operator visits a relying-party site for the first time; the site requests a proxied login with no entryId, the wallet resolves that no persona is bound yet, prompts the operator to pick an identit
  • Returning-User Proxied SIOP Login: An operator revisits a site that already has a bound persona; the wallet resolves the existing vault entry using strict origin matching and only raises a gated consent prompt before completing the sig

📋 Risk Registry (9)

ID Title Severity Residual Priority Effort
RISK-1 Extension messaging surface lacks sender/origin verification for consent results, enabling forged approvals from co-installed malicious extensions. High Medium Immediate Low
RISK-2 Origin-to-RP-DID trust binding accepts unverified page-supplied targetDid, corrupting the swap-detection baseline. Medium Medium Short-Term Medium
RISK-3 Consent popup lacks a client-side single-decision guard, permitting a theoretical duplicate-decide race that could desynchronize UI intent from background state. Medium Low Short-Term Low
RISK-4 Fixed-height consent popup sizing may not accommodate combined persona-picker and warning content, risking blind approval. Medium Low Medium-Term Medium
RISK-5 Shared free-text action/reason parameters in requestConsent create latent risk of consent-prompt social engineering if misused at other call sites. Medium Low Medium-Term Medium
RISK-6 Coarse origin-prefix filtering in vault list queries could permit enumeration of bound origins by near-match domains if underlying matching is imprecise. Medium Low Medium-Term Medium
RISK-7 No debounce/rate-limit on proxyLogin allows first-use consent popup spam, degrading decision quality through consent fatigue. Low Low Medium-Term Low
RISK-8 Absence of an independent, tamper-evident audit log for consent and binding decisions limits forensic reconstruction after a dispute or suspected compromise. Low Low Long-Term Medium
RISK-9 Page-facing error messages may leak internal details (signing key counts, attempted DID) to relying-party JavaScript on failure paths. Low Low Long-Term Low

⚔️ Attack Scenarios (3)

SC-2: Background Service Worker

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC2@{ shape: rect, label: "SC-2: Background Service Worker" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE346@{ shape: rect, label: "CWE-346: Origin Validation Error" }
    CWE290@{ shape: rect, label: "CWE-290: Authentication Bypass by Spoofing" }
    CWE367@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
    CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC98@{ shape: rect, label: "CAPEC-98: Phishing / Message Spoofing" }
    CAPEC25@{ shape: rect, label: "CAPEC-25: Forced Deadlock/Race Exploitation" }
    CAPEC459@{ shape: rect, label: "CAPEC-459: Creating a Rogue Certification Authority Certificate (Trust Poisoning Analogy)" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    T1@{ shape: rect, label: "STRIDE-1: Unvalidated Sender Spoofing<br><i>High / Likely</i>" }
    T3@{ shape: rect, label: "STRIDE-3: Consent Bypass Race Condition<br><i>Medium / Possible</i>" }
    T8@{ shape: rect, label: "STRIDE-8: Cross-Context DID Confusion<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA2@{ shape: rect, label: "TA-2: Co-Installed Malicious Extension<br><i>Spoof consent approvals</i>" }
    TA1@{ shape: rect, label: "TA-1: Malicious Relying Party<br><i>Obtain unauthorized assertions</i>" }
  end
  SC2 --> CWE290
  SC2 --> CWE367
  SC2 --> CWE345
  CWE290 --> CAPEC98
  CWE367 --> CAPEC25
  CWE345 --> CAPEC459
  CAPEC98 --> T1
  CAPEC25 --> T3
  CAPEC459 --> T8
  T1 --> TA2
  T3 --> TA2
  T8 --> TA1
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FF0000,stroke-width:2px
  linkStyle 8 stroke:#FF0000,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FF0000,stroke-width:2px
Loading

SC-3: Confirm Popup UI

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC3@{ shape: rect, label: "SC-3: Confirm Popup UI" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE1021@{ shape: rect, label: "CWE-1021: Improper Restriction of Rendered UI Layers" }
    CWE451@{ shape: rect, label: "CWE-451: UI Misrepresentation of Critical Information" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC103@{ shape: rect, label: "CAPEC-103: Clickjacking" }
    CAPEC163@{ shape: rect, label: "CAPEC-163: Spear Phishing" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    T7@{ shape: rect, label: "STRIDE-7: Popup Window Bounds Manipulation<br><i>Medium / Possible</i>" }
    T10@{ shape: rect, label: "STRIDE-10: Prompt Injection via action/reason<br><i>Medium / Possible</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA3@{ shape: rect, label: "TA-3: Curious/Impatient Operator<br><i>Approve without full review</i>" }
    TA1@{ shape: rect, label: "TA-1: Malicious Relying Party<br><i>Manipulate consent wording</i>" }
  end
  SC3 --> CWE1021
  SC3 --> CWE451
  CWE1021 --> CAPEC103
  CWE451 --> CAPEC163
  CAPEC103 --> T7
  CAPEC163 --> T10
  T7 --> TA3
  T10 --> TA1
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FF0000,stroke-width:2px
Loading

SC-4: Vault Storage

---
config:
  layout: dagre
  look: classic
  theme: dark
---
flowchart LR
  subgraph SL1["1. System Component"]
    direction LR
    SC4@{ shape: rect, label: "SC-4: Vault Storage" }
  end
  subgraph SL2["2. Weaknesses"]
    direction LR
    CWE200@{ shape: rect, label: "CWE-200: Exposure of Sensitive Information" }
    CWE209@{ shape: rect, label: "CWE-209: Information Exposure Through Error Message" }
    CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
  end
  subgraph SL3["3. Attack Patterns"]
    direction LR
    CAPEC116@{ shape: rect, label: "CAPEC-116: Excavation (Enumeration)" }
    CAPEC118@{ shape: rect, label: "CAPEC-118: Data Leakage Attacks" }
    CAPEC593@{ shape: rect, label: "CAPEC-593: Session Hijacking (Audit Gap Analogy)" }
  end
  subgraph SL4["4. Threats"]
    direction LR
    T2@{ shape: rect, label: "STRIDE-2: Persona Enumeration<br><i>Medium / Possible</i>" }
    T5@{ shape: rect, label: "STRIDE-5: Signing Key Ambiguity Disclosure<br><i>Low / Unlikely</i>" }
    T6@{ shape: rect, label: "STRIDE-6: RP DID Disclosure<br><i>Low / Possible</i>" }
    T11@{ shape: rect, label: "STRIDE-11: Missing Audit Trail<br><i>Low / Unlikely</i>" }
  end
  subgraph SL5["5. Threat Actors"]
    direction LR
    TA1@{ shape: rect, label: "TA-1: Malicious Relying Party<br><i>Profile operator vault contents</i>" }
  end
  SC4 --> CWE200
  SC4 --> CWE209
  SC4 --> CWE778
  CWE200 --> CAPEC116
  CWE209 --> CAPEC118
  CWE778 --> CAPEC593
  CAPEC116 --> T2
  CAPEC118 --> T5
  CAPEC118 --> T6
  CAPEC593 --> T11
  T2 --> TA1
  T5 --> TA1
  T6 --> TA1
  T11 --> TA1
  linkStyle 0 stroke:#FF0000,stroke-width:2px
  linkStyle 1 stroke:#FF0000,stroke-width:2px
  linkStyle 2 stroke:#FF0000,stroke-width:2px
  linkStyle 3 stroke:#FF0000,stroke-width:2px
  linkStyle 4 stroke:#FF0000,stroke-width:2px
  linkStyle 5 stroke:#FF0000,stroke-width:2px
  linkStyle 6 stroke:#FF0000,stroke-width:2px
  linkStyle 7 stroke:#FF0000,stroke-width:2px
  linkStyle 8 stroke:#FF0000,stroke-width:2px
  linkStyle 9 stroke:#FF0000,stroke-width:2px
  linkStyle 10 stroke:#FF0000,stroke-width:2px
  linkStyle 11 stroke:#FF0000,stroke-width:2px
  linkStyle 12 stroke:#FF0000,stroke-width:2px
Loading

📊 Risk Summary

Total Threats: 11

By Severity: Low: 5 · High: 1 · Medium: 5

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

🎯 Attack Surface

Kill Chain 1: A co-installed malicious extension (TA-2) exploits the unauthenticated RUNTIME_CONSENT_RESULT listener (STRIDE-1) to forge an 'approved: true' decision for a pending first-use persona-binding consentId, and because the required selectedDid must still name a DID the operator's agent actually hosts (bindProfileEntry's re-validation), the attacker cannot fabricate an arbitrary identity — but CAN force-approve a binding of the operator's own legitimate persona to an origin they never consented to, silently establishing a persistent origin-persona association (RA-1) that persists across future 'gated' consent short-circuits, effectively converting a single message-spoofing act into a durable identity-disclosure relationship. Kill Chain 2: A malicious relying party (TA-1) combines the coarse origin-prefix vault enumeration weakness (STRIDE-2) with the unauthenticated targetDid trust-poisoning gap (STRIDE-8): first probing near-match origins to infer which sites the operator has bound personas to, then registering a typosquat domain and inducing a first-use consent approval with 'remember' ticked to poison the RP-DID baseline for that origin — corrupting the M5 swap-detection control before it ever has a legitimate value to compare against, setting up a later downgrade attack where a genuinely malicious RP DID swap goes undetected. Kill Chain 3: The combination of prompt-spam denial-of-service (STRIDE-9) and insufficient popup sizing (STRIDE-7) creates a compound human-factors attack: a malicious page floods the operator with first-use consent popups, and on a screen where the persona-picker list is long enough to push Approve/Deny off-screen, the operator — fatigued by repeated prompts — is statistically more likely to blind-approve, scrolling past content they have not read, at which point the missing audit trail (STRIDE-11) ensures no durable record exists to later confirm whether the resulting binding reflected genuine informed consent.

🛡️ Risk Mitigation Strategy

Priority 1 (Immediate): Close the sender/origin verification gap on the RUNTIME_CONSENT_RESULT listener (RISK-1) — this is the single highest-severity, highest-likelihood control gap and the root enabler of the most damaging kill chain; implementation is low-effort (sender.id/url checks plus window-ID binding of pendingConsents) relative to its risk reduction, and should ship alongside the reviewed PR rather than as a follow-up. Priority 2 (Short-Term): Independently verify page-supplied targetDid before persisting trust via trustOrigin (RISK-2), and add a client-side single-decision guard in confirm.tsx mirroring the background's settled flag (RISK-3) — both address integrity gaps in trust-establishment logic that are currently masked by 'the operator reviewed it' assumptions the human-factors kill chain shows are not fully reliable. Priority 3 (Medium-Term): Harden the human-in-the-loop UI itself — scrollable persona lists with guaranteed-visible action buttons (RISK-4), enum-based action/reason parameters eliminating free-text social-engineering surface in requestConsent (RISK-5), precise origin-prefix matching semantics in vault list queries (RISK-6), and per-origin consent-request coalescing/rate-limiting (RISK-7) — collectively these close the compound human-factors and enumeration risks without requiring architectural change. Priority 4 (Long-Term): Build the missing audit/observability layer — append-only, structured logging of every consent decision and binding event with an operator-facing activity history (RISK-8) — and sanitize page-facing error paths to stop leaking internal signing-key/DID details on failure (RISK-9); these are lower-likelihood, lower-immediate-impact gaps but materially improve the organization's ability to detect, investigate, and respond to exploitation of the higher-priority issues once Priorities 1–3 are addressed.


Generated by Agentic Sec — Threat Model & Affect Analysis Agent

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

Must-Review-By-Human (3)

  • 🟡 Trusted-origin "remember" flag reused across RP DID targets when trusting an origin after first-use profile binding
  • 🟠 First-use persona binding trusts consent-window-supplied DID without independent verification tie to origin
  • 🟡 First-use persona binding uses remember consent to silently trust future persona/RP-DID combinations without re-confirmation

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