fix(vtc-admin): let the wallet choose the identity, instead of reading the vault - #1212
Conversation
…g the vault
Proxy sign-in began by asking the wallet to enumerate every vault entry
pinned to this VTC, so it could find one:
vaultList({ targetDid, secretKind: "didSelfIssued" })
Two problems, and the second is the one operators hit. It is a
disclosure of the operator's vault to answer a question about a single
entry — and it costs its own consent prompt to make. And on a wallet
with nothing pinned here it returns an empty array, at which point the
page gave up with "No did-self-issued vault entry is pinned to this VTC.
Open the wallet, add an entry targeting this VTC's DID, then retry" —
a setup step in another application, quoted at someone who was trying to
sign in.
The wallet now owns that question. `walletProfile({ target })` resolves
the entry bound to this origin, or asks the operator which identity to
use and remembers the answer, and returns the persona DID with the entry
id. It mints nothing.
const { did, entryId } = await walletProfile({ target });
const { challenge } = await POST /auth/challenge { did };
await proxyLogin({ entryId, nonce: challenge, target });
The DID has to be known before the challenge, because `handle_challenge`
binds the nonce to it — which is why this is two calls and not one, and
why `proxyLogin` alone could never have reached it.
Round trips are unchanged: the profile call replaces the vault listing
one for one, and passing `entryId` back means `proxyLogin` does not
repeat the lookup. One fewer consent prompt in the steady state.
**A first sign-in now says which DID needs admitting.** The persona was
created a moment ago, so this VTC has never seen it and `handle_challenge`
refuses on the ACL gate — a 403 an operator cannot act on. `bound: true`
tells us this was the first time, so the message names the DID and the
`vtc admin invite --did …` that fixes it.
**Choosing among several identities is kept, as an explicit action.** An
operator may hold both an Admin and a member persona here, and the
wallet returns the one bound to this origin. "Sign in as a different
identity…" still runs the old enumeration — behind a click, because
reaching it discloses the vault to this page. It is also the only proxy
route on a wallet build that predates `walletProfile`, which is what
`isWalletProfileAvailable()` distinguishes: a capability probe that
produces an accurate message, not a compatibility fold.
`loginWithWalletProxy` now delegates to a shared `runProxySiop`, so the
wallet-resolved and hand-picked paths apply exactly the same rule.
Requires OpenVTC/vta-browser-plugin#145. `proxyLogin` and `vaultList`
are untouched there, so this can land in either order — the button
degrades to the picker until the wallet ships.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`isWalletProfileAvailable()` existed so #1212 could merge before the wallet method it depends on did. That has now shipped (OpenVTC/vta-browser-plugin#145), so the second probe describes a wallet build that does not exist: every extension that exposes `proxyLogin` exposes `walletProfile` too. Folded into `isWalletProxyAvailable()`, which now checks the three methods this page actually calls. The proxy button is no longer conditionally hidden, and the secondary button no longer changes its label to stand in as the primary route — both were arms for a wallet nobody has. Presence detection stays and is not the same thing: the extension may simply not be installed, which is why the buttons are gated at all. Nothing about the flow changes. `walletProfile` → `/auth/challenge` → `proxyLogin` is untouched, and "Sign in as a different identity…" keeps the entry picker for an operator holding more than one persona here. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review2 AI-confirmed issues, 1 finding needs a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #1212
🗺️ Scan CoverageModules scanned: 1 · with findings: 1 · files: 2 · findings: 5
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (2)🟡 Sensitive DID and privileged CLI command disclosed in client-rendered error message
🧠 AI Triage:
Summary: The first-sign-in error path in loginWithWalletProfile() embeds a raw persona DID and the exact admin-invite CLI command into a client-visible Error message, which Login.tsx then renders in the DOM, exposing sensitive identity and administrative-workflow detail to anything with DOM/screen access. 📝 Description: Exposes the operator's persona DID and the exact privileged CLI syntax used to grant admin access, which can be captured via screen-recording, malicious extensions with DOM access, or shared support screenshots, aiding social engineering against VTC administrators. 🧪 Proof of Concept: The comment explicitly states 'the DID is not otherwise on screen anywhere' as the justification for embedding it, confirming this is an intentional but risky design choice that places sensitive identity/administrative detail directly into user-facing text. Vulnerable lines: 237, 264 🔎 Evidence: 💥 Impact: Facilitates social engineering of an admin into inviting an attacker-controlled DID, or leaks identity linkage information that should be handled through a secured admin channel. Confidentiality: Low-Medium - discloses a persona DID and privileged admin command syntax to anything observing the DOM · Integrity: None directly · Availability: None 🧭 Reachability:
⚖️ Triage Factors:
Attack scenario: A first-time operator sign-in rejected by the VTC's ACL results in the raw DID and the literal admin-invite CLI command being rendered into the page, exposing information useful for social engineering to anyone observing the screen or DOM. 🔧 Remediation:
Move the DID and the precise admin CLI invocation out of the client-rendered error text into a server-side/audit log, or behind an explicit 'copy details' action gated by user intent, so it is not passively exposed to anything with DOM read access (extensions, screen capture, support screenshots). Vulnerable code: Secure code: Additional recommendations:
🟡 Client accepts holderDid from caller input without verifying it against the signed proxy-login result (TOCTOU / missing consistency check)
🧠 AI Triage:
🔎 Evidence: 💥 Impact: Undermines the client's ability to detect a race condition or malicious extension substituting a different signing identity between resolution and login, in a system whose entire purpose is verifiable trust/identity. Confidentiality: Low · Integrity: Medium - a session could be established and displayed as belonging to a different identity than actually authenticated by the signed token, weakening non-repudiation and client-side audit accuracy · Availability: None 🧭 Reachability:
⚖️ Triage Factors:
Attack scenario: The client trusts the DID it requested as the authoritative 'holderDid' in the returned login result without confirming that the underlying signed token/session actually corresponds to that DID, which is a defense-in-depth gap exploitable in a race condition or in combination with a spoofed wallet (VULN-001). 🔧 Remediation:
Instead of echoing back the DID that was requested, the client should read the authenticated subject from the server's verified response (e.g. tokenResp.session or a decoded/verified id_token claim) and explicitly compare it to what was requested, failing closed on mismatch. Vulnerable code: Secure code: Additional recommendations:
|
| Field | Detail |
|---|---|
| Severity | HIGH |
| Location | vtc-service/admin-ui/src/lib/wallet.ts:138 |
| Finding ID | github_pr-5e3fa63c21d2 |
| CWE | CWE-346, CWE-829, CWE-287 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| MITRE ATT&CK | T1185, T1176 |
| CAPEC | CAPEC-151, CAPEC-194 |
| CVSS 4.0 | 8.1 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N) |
| DREAD | 6 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | poc |
| Detection Source | skill_scan |
Summary: isWalletProfileAvailable() and the surrounding wallet-bridge code trust window.vtaWallet purely by duck-typing (checking a function exists), with no verification that the object was injected by the legitimate browser extension, allowing a script running in the same origin to spoof the wallet and control identity resolution for login.
📝 Description:
An attacker with script execution in the admin-ui origin can make the application believe an attacker-controlled wallet extension is present, causing the primary login flow to send an attacker-chosen DID to /auth/challenge and to accept an attacker-controlled proxyLogin() response, potentially completing a session as an attacker-chosen or manipulated identity.
🧪 Proof of Concept:
This function is the sole gate determining whether the wallet-resolved (default, primary-button) login path is trusted. It performs only a typeof-function duck-type check, which any script in the page context can satisfy by assigning its own object to window.vtaWallet.
/** True iff the wallet can resolve-or-bind a persona for this origin itself.
*
* A capability probe, not a compatibility fold: without it the proxy path can
* only work for an operator who has already bound an entry by hand, and the
* difference is worth an accurate message rather than a `TypeError`. */
export function isWalletProfileAvailable(): boolean {
return (
isWalletProxyAvailable() &&
typeof window.vtaWallet?.walletProfile === "function"
);
}
Vulnerable lines: 138, 144
🔎 Evidence: vtc-service/admin-ui/src/lib/wallet.ts:138
export function isWalletProfileAvailable(): boolean {
return (
isWalletProxyAvailable() &&
typeof window.vtaWallet?.walletProfile === "function"
);
}
💥 Impact:
Compromise of the admin identity-binding trust decision could let an attacker impersonate a VTC administrator persona, subverting the entire verifiable-trust infrastructure's authentication guarantees.
Confidentiality: High - attacker-controlled identity resolution can expose which DID/persona is used and steer server-side session establishment · Integrity: High - login flow can complete under an attacker-chosen or attacker-substituted identity · Availability: None directly
🧭 Reachability:
- Network exposure: public
- Auth barrier: none
- Attack path: EP-004 (Login button) → handleProxyStart (Login.tsx) → loginWithWalletProfile (wallet.ts) → isWalletProfileAvailable() / window.vtaWallet.walletProfile() → runProxySiop() → EP-001 (/auth/challenge) + EP-002 (proxyLogin)
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | high |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: An attacker with any script-execution foothold in the admin-ui origin can define a fake window.vtaWallet object that the app trusts by mere typeof check, letting the attacker control identity resolution for the wallet-proxied SIOP login.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Add an attestation/handshake step (e.g. a signed token or postMessage exchange bound to the extension's known ID) so the page can distinguish the genuine extension-injected object from an arbitrary script-defined global. Combine with a strict CSP and Subresource Integrity to reduce the chance of a rogue script running in this origin in the first place.
Vulnerable code:
export function isWalletProfileAvailable(): boolean {
return (
isWalletProxyAvailable() &&
typeof window.vtaWallet?.walletProfile === "function"
);
}
Secure code:
export function isWalletProfileAvailable(): boolean {
return (
isWalletProxyAvailable() &&
typeof window.vtaWallet?.walletProfile === "function" &&
isTrustedWalletOrigin(window.vtaWallet)
);
}
// Verify the object was injected by the genuine extension, e.g. via a
// signed handshake / postMessage exchange with a known extension ID,
// rather than trusting an arbitrary page-global.
function isTrustedWalletOrigin(candidate: unknown): boolean {
return typeof (candidate as { __vtaAttestation?: string })?.__vtaAttestation === "string" &&
verifyExtensionAttestation((candidate as { __vtaAttestation: string }).__vtaAttestation);
}
Additional recommendations:
- Enforce a strict Content-Security-Policy (script-src 'self' + hashes/nonces) to reduce injection surface.
- Use postMessage with explicit origin/extension-ID checks instead of a mutable global object where possible.
- After proxyLogin/token exchange, independently verify the SIOP id_token's signed subject DID matches the DID requested (see VULN-003) as defense-in-depth.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: wallet.ts defines
export function isWalletProfileAvailable(): boolean { return (isWalletProxyAvailable() && typeof window.vtaWallet?.walletProfile === "function"); }andisWalletProxyAvailablesimilarly only checkstypeof window.vtaWallet?.walletProfile === "function"etc. There is no origin pinning, no signature/attestation check onwindow.vtaWalletbefore trustingwalletProfile()'s returned did/entryId, which then flow intorunProxySiop(profile.did, profile.entryId)- 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.
Details
🛡️ Threat Model & Affect Analysis — PR #1212
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-trust-infrastructure |
| Branch |
feat/wallet-profile-resolution → main
|
| Generated | 2026-09-05 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.
📋 Affect Analysis
Change Summary
This PR introduces a new wallet-driven persona-resolution flow (walletProfile) for VTC admin login, replacing the default full-vault-enumeration flow (listProxyCandidates) with a mechanism where the browser extension itself resolves or binds the correct persona DID for a given VTC origin. It also refactors the shared challenge/mint/exchange logic into a common runProxySiop helper and adds first-use-binding error messaging that guides admins toward ACL admission.
Diff: +245 / -32 lines
Types: feature, security, refactor
⚠️ Security Implications
🟠 Unauthenticated window.vtaWallet global enables full impersonation of the new persona-resolution capability
Unauthenticated window.vtaWallet global enables full impersonation of the new persona-resolution capability
Action: Replace the mutable-global trust model with origin-checked postMessage-based extension messaging or require extension attestation (e.g., signed manifest ID) before treating window.vtaWallet as authoritative. Add CSP/SRI hardening to reduce the script-injection surface that could plant a fake global.
🟡 proxyLogin.entryId wire contract loosened from required to optional without runtime enforcement
proxyLogin.entryId wire contract loosened from required to optional without runtime enforcement
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| admin-ui-wallet-lib (COMP-001) | critical | modified | New persona-resolution capability (walletProfile/isWalletProfileAvailable/loginWithWalletProfile) added; shared runProxySiop helper extracte |
| admin-ui-login-page (COMP-002) | high | modified | Primary sign-in button's default action switched from entry-enumeration to wallet-auto-resolution; a new secondary escape-hatch button retai |
| vta-wallet-extension (COMP-003, external/untrusted) | high | modified | Not modified in this diff, but this PR increases the admin-ui's reliance on this external, unauthenticated browser-injected object for a new |
📁 File Classifications
vtc-service/admin-ui/src/lib/wallet.ts
- Type: security
vtc-service/admin-ui/src/pages/Login.tsx
- Type: security
🛡️ STRIDE Threat Model
Identified Threats (10)
🟠 STRIDE-1: Extension-Origin Spoofing via window.vtaWallet Global Object
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Information Disclosure |
| Severity | High |
| Likelihood | Likely |
| CVSS | 8.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-346,CWE-829 |
| CAPEC | CAPEC-151,CAPEC-194 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: window.vtaWallet global object in wallet.ts allows spoofing of the browser-extension wallet interface due to unauthenticated reliance on a page-injected global, resulting in impersonation of the wallet and theft of the persona DID / proxyLogin flow.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~138-145
export function isWalletProfileAvailable(): boolean {
return (
isWalletProxyAvailable() &&
typeof window.vtaWallet?.walletProfile === "function"
);
}
Attack Scenario:
- Attacker delivers a malicious script into the admin-ui origin via a separate XSS, malicious browser extension, or compromised dependency.
- Malicious script defines
window.vtaWallet = { walletProfile: async () => ({ did: attackerDid, entryId: attackerEntryId, bound: true }) }before the legitimate extension loads or by overwriting the property. -
isWalletProfileAvailable()in wallet.ts only checkstypeof window.vtaWallet?.walletProfile === "function", so the spoofed object passes the capability probe. -
loginWithWalletProfile()callswindow.vtaWallet!.walletProfile!({...})and trusts the returnedprofile.didandprofile.entryIdunconditionally. -
runProxySiop(profile.did, profile.entryId)sends{ did: principalDid }to/auth/challenge(EP-001) and then callswindow.vtaWallet!.proxyLogin!({ entryId, nonce, target }), both of which are attacker-controlled if the attacker fully controls the fakevtaWallet. - If attacker also controls
proxyLogin's response (mints a forged/co-opted id_token or wires the flow to their own daemon), the login completes with an identity the attacker chose.
🔎 Threat Clue: Derived from COMP-003 via EP-002, EP-003
- Data Flows: Browser page <-> window.vtaWallet extension bridge
Preconditions: Attacker must be able to execute JavaScript in the admin-ui page context before/instead of the real extension (e.g., via a supply-chain compromise, a malicious/typosquatted extension, or an XSS elsewhere in the app)., No integrity check exists confirming window.vtaWallet was injected by the genuine, expected browser extension.
Existing Controls: isWalletProfileAvailable() performs a basic typeof-function capability probe (not an identity/authenticity check). • Server-side ACL gate in handle_challenge restricts which DIDs can complete a challenge.
Recommended Mitigations: Use Object.freeze/content-script isolation and origin-bound extension messaging (e.g., postMessage with strict origin checks) instead of a mutable global. • Add extension attestation (e.g., signed manifest ID check) before trusting window.vtaWallet. • Enforce Content-Security-Policy and Subresource Integrity to reduce script-injection surface that could plant a fake global.
🟡 STRIDE-2: Optional entryId Parameter Enables Ambiguous/Forged proxyLogin Requests
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-20,CWE-354 |
| CAPEC | CAPEC-137 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: proxyLogin() in the VtaWalletProvider interface in wallet.ts allows tampering via an ambiguous request due to entryId being changed from a required field to an optional field, resulting in potential use of a wallet-selected default entry instead of the operator-intended entry.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:84-95
proxyLogin?(params: {
entryId?: string;
nonce?: string;
target?: { kind: string; [k: string]: unknown };
ttlSecondsHint?: number;
}): Promise<ProxyLoginWireResult>;
Attack Scenario:
- The diff changes
proxyLogin?(params: { entryId: string; ... })toproxyLogin?(params: { entryId?: string; ... })in theVtaWalletProviderinterface (wallet.ts lines ~84-95). -
runProxySiop()is now the single call site that always suppliesentryId, but the type now permits any future or third-party caller to omit it. - A malformed or malicious caller path (e.g., a future code change, or a compromised dependency invoking
window.vtaWallet.proxyLogindirectly) can invokeproxyLogin({ nonce, target })withoutentryId. - If the underlying browser-extension implementation falls back to a default/last-used vault entry when
entryIdis omitted, this silently authenticates as an unintended persona. - The resulting id_token is exchanged at the token endpoint, establishing a session under the wrong or unintended DID with no client-side validation that the returned
holderDidmatches the DID the operator selected.
🔎 Threat Clue: Derived from COMP-003 via EP-002
- Data Flows: admin-ui -> window.vtaWallet.proxyLogin
Preconditions: The browser extension's actual proxyLogin implementation must have (or later acquire) a permissive fallback behavior for missing entryId., Some code path (current or future) must call proxyLogin without going through runProxySiop's guaranteed entryId supply.
Existing Controls: Current production code path (runProxySiop) always supplies a non-empty entryId. • Server-side ACL binds /auth/challenge to a specific DID.
Recommended Mitigations: Keep entryId required in the TypeScript interface; introduce a separate, explicitly-named method (e.g., proxyLoginDefault) if a defaulting behavior is truly desired. • Add a runtime assertion in runProxySiop verifying entryId and profile.did are non-empty before calling proxyLogin. • Validate that pl/tokenResp returned holderDid matches the requested principalDid before accepting the session.
🟡 STRIDE-3: DID and Admin CLI Command Disclosure in Client-Rendered Error Message
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Very Likely |
| CVSS | 4.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 | Low |
| CWE | CWE-209,CWE-200 |
| CAPEC | CAPEC-116 |
| OWASP | A01:2021 - Broken Access Control |
Description: loginWithWalletProfile() error handler in wallet.ts allows information disclosure of an operator's freshly-bound persona DID and an admin invitation command due to embedding sensitive identifiers directly into a client-facing error string, resulting in exposure of internal ACL-administration workflow and identity linkage to anyone viewing the browser UI/console/screen.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~255-266
const message = err instanceof Error ? err.message : String(err);
throw new Error(
`${message}\n\nThis was the first sign-in as ${profile.did}. ` +
"If the VTC refused it, that DID needs an Admin entry in this VTC's ACL — " +
`ask another admin to run \`vtc admin invite --did ${profile.did
Attack Scenario:
- Operator attempts first-time sign-in via
loginWithWalletProfile(); the wallet binds a brand-new persona and setsprofile.bound = true. -
runProxySiop(profile.did, profile.entryId)fails because the VTC's/auth/challengeACL gate rejects the unknown DID (403). - The catch block in
loginWithWalletProfile()constructs:`${message}\n\nThis was the first sign-in as ${profile.did}. ... ask another admin to run \`vtc admin invite --did ${profile.did}\`.`. - This string, containing the operator's persona DID and the exact admin CLI invocation, is displayed in the Login.tsx UI (
walletPhase.message), and may be captured by shoulder-surfing, screen recording, browser extensions with DOM access, error-tracking/analytics tools, or supportive screenshots shared over insecure channels. - An attacker with access to this message (e.g., a malicious browser extension reading the DOM, or a support ticket screenshot) learns the exact DID needing admission and the precise command an admin would run, aiding social-engineering of an admin into inviting an attacker-controlled DID.
🔎 Threat Clue: Derived from COMP-002 via EP-004
- Data Flows: wallet.ts loginWithWalletProfile -> Login.tsx UI error display
Preconditions: Operator must attempt a first-time proxy sign-in that gets rejected by the ACL., Some entity (malicious extension, screen-capture, support channel) must be able to observe the rendered error text.
Existing Controls: Error is only shown to the operator attempting sign-in, not broadcast externally. • Server independently validates ACL membership regardless of what the client displays.
Recommended Mitigations: Avoid embedding the raw admin CLI command with an interpolated DID in client-visible text; instead show a generic instruction and log the DID server-side or in a copy-to-clipboard control gated behind explicit user action. • Redact or truncate DIDs shown in UI error text (e.g., show only a DID fragment) and provide full detail via a secured admin-only channel. • Ensure no automatic telemetry/error-reporting SDK captures this message verbatim without redaction.
🟡 STRIDE-4: Missing DID Consistency Check Between walletProfile and runProxySiop Result
| Field | Detail |
|---|---|
| Category | Tampering, Elevation of Privilege |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-367,CWE-863 |
| CAPEC | CAPEC-26 |
| OWASP | A01:2021 - Broken Access Control |
Description: loginWithWalletProfile() in wallet.ts allows tampering via a TOCTOU-style identity mismatch due to trusting profile.did/profile.entryId without verifying the eventual session's holderDid matches what was requested, resulting in the operator unknowingly authenticating with an unintended or attacker-substituted persona.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~296-355
return {
accessToken: tokenResp.tokens.accessToken,
refreshToken: tokenResp.tokens.refreshToken ?? "",
sessionId: tokenResp.session.id,
holderDid: principalDid,
};
Attack Scenario:
-
window.vtaWallet!.walletProfile!({...})returns{ did, entryId, bound }(wallet.ts) which the code treats as authoritative for which persona will be used. - Between this call and the subsequent
runProxySiop(profile.did, profile.entryId), the wallet extension's internal state, vault, or a compromised/racing extension process could change which entryentryIdmaps to. -
runProxySiopsends{ did: principalDid }to/auth/challengeand separately callsproxyLogin({ entryId, nonce, target })— these are two independent calls to two different systems (VTC server, browser extension) with no atomic binding guarantee between the DID sent to the server and the entry the extension actually uses to sign. -
VtaWalletLoginResultreturned includesholderDid: principalDid— this value is simply the value passed in, NOT independently re-derived from the actual signed SIOP id_token/proxyLogin result (pl), meaning the client never confirms the extension actually used the same DID/entry that was requested. -
finishWithBearer(result.accessToken)(Login.tsx) accepts the resulting access token without the UI layer re-validating that the authenticated identity matchesprofile.didshown to the user, completing a session as a potentially different identity than displayed.
🔎 Threat Clue: Derived from COMP-003 via EP-002, EP-003
- Data Flows: walletProfile response -> runProxySiop -> proxyLogin -> token exchange
Preconditions: Wallet extension's internal entry-to-DID binding must be mutable/race-prone between the two calls, or a second malicious extension/script must interleave., No cryptographic binding ties the walletProfile response to the proxyLogin response other than the caller-supplied entryId/did strings.
Existing Controls: Server-side /auth/challenge binds the nonce to a requested DID; the SIOP id_token itself is cryptographically signed by the wallet's key for a specific DID, which downstream token-minting presumably validates. • entryId is passed to proxyLogin giving the extension the specific vault entry to use, reducing (but not eliminating) ambiguity.
Recommended Mitigations: After proxyLogin/token exchange completes, explicitly compare the SIOP id_token's sub/DID claim (or tokenResp.session's bound DID) against profile.did client-side before calling finishWithBearer, and abort with a clear error on mismatch. • Cryptographically bind the walletProfile resolution to the subsequent proxyLogin call (e.g., via a short-lived resolution token/nonce issued by the extension itself). • Log and surface any DID mismatch as a distinct, alertable client-side event.
🔵 STRIDE-5: Default-to-Profile-Path UI Change Silently Alters Trust Decision Without User Confirmation
| Field | Detail |
|---|---|
| Category | Repudiation, Spoofing |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-346,CWE-778 |
| CAPEC | CAPEC-141 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: handleProxyStart() in Login.tsx allows repudiation-adjacent ambiguity via a UI relabeling that swaps the primary sign-in action from an explicit entry-picking flow to an implicit wallet-resolved persona flow due to profileAvailable replacing proxyAvailable as the gating condition, resulting in an operator unknowingly authenticating as a wallet-remembered identity rather than the one they intended to pick.
Evidence: vtc-service/admin-ui/src/pages/Login.tsx:~267-276
{profileAvailable && (
<button type="button" className="secondary" onClick={handleProxyStart} disabled={busy}>
...
</button>
)}
Attack Scenario:
- Before the diff, the primary button was gated on
proxyAvailableand invokedhandleProxyStartbound to the entry-listing/-choosing flow. - After the diff, the same visual primary button position is now gated on
profileAvailableand silently callsloginWithWalletProfile()instead — a materially different trust decision (wallet auto-resolves identity vs. operator explicitly picks it). - An operator accustomed to the old behavior clicks the now-relabeled/rewired primary button expecting an explicit-choice flow, but instead authenticates as whatever persona the wallet auto-resolved or auto-bound on first use.
- If the auto-bound persona differs from the operator's intended identity (e.g., wallet defaulted to a lower-privilege or unexpected persona), the operator completes a session under an unintended identity without a clear confirmation step.
- Because the resulting session is fully valid (server ACL permitting), there is no server-side signal distinguishing "operator explicitly chose this identity" from "wallet auto-resolved this identity," weakening non-repudiation of the identity-selection decision.
🔎 Threat Clue: Derived from COMP-002 via EP-004
- Data Flows: Operator click -> handleProxyStart -> loginWithWalletProfile
Preconditions: Operator must be unaware of the semantic change introduced by this PR (no explicit re-confirmation UI added)., Wallet must have more than one bindable persona or must auto-bind a new one silently.
Existing Controls: handleChooseIdentity remains available as a secondary/manual escape hatch for explicit selection. • Comment in code explains rationale, but this is not surfaced to end users.
Recommended Mitigations: Add an explicit confirmation step showing the resolved DID before finalizing loginWithWalletProfile(), especially on first bind (profile.bound === true). • Provide a persistent UI indicator of which identity/DID is currently bound for this origin, independent of which button was clicked. • Log identity-selection method (auto-resolved vs. manually chosen) server-side for audit trails.
🟡 STRIDE-6: No Rate Limiting Visible on /auth/challenge Enables DID Enumeration and Challenge Flooding
| Field | Detail |
|---|---|
| Category | Denial of Service, Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-307,CWE-799 |
| CAPEC | CAPEC-125,CAPEC-112 |
| OWASP | A04:2021 - Insecure Design |
Description: POST /auth/challenge endpoint (EP-001) allows denial-of-service and DID-enumeration due to the unauthenticated challenge-issuance flow being invocable with any attacker-supplied did value from runProxySiop/loginWithWalletProfile, resulting in resource exhaustion or inference of which DIDs are ACL-admitted based on differing error responses.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~280-290
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ did: principalDid }),
Attack Scenario:
-
runProxySiop()in wallet.ts POSTs{ did: principalDid }to${base}/auth/challengewithcredentials: "include"but no visible authentication requirement (EP-001 markedauth_required: falsein recon). - An attacker crafts direct HTTP requests to
/auth/challengewith a large volume of distinct or repeateddidvalues, bypassing the UI entirely. - If the server's ACL check response (403 vs 200-with-challenge) differs observably in timing or content between admitted and non-admitted DIDs, an attacker can enumerate which DIDs are valid Admin entries.
- Simultaneously, sending high volumes of challenge requests may exhaust server resources (nonce generation/storage) absent rate limiting, degrading availability for legitimate admins attempting to sign in.
- This directly weaponizes the same code path introduced/modified by this PR (
runProxySiop's/auth/challengecall), now reachable via two client flows (loginWithWalletProfileandloginWithWalletProxy) but fundamentally exploitable independent of the UI.
🔎 Threat Clue: Derived from COMP-004 via EP-001
- Data Flows: runProxySiop -> POST /auth/challenge
Preconditions: /auth/challenge has no rate limiting or CAPTCHA-equivalent control (not shown in provided source, assumed absent given recon marks it unauthenticated)., Server response for admitted vs non-admitted DID must be distinguishable to enable enumeration.
Existing Controls: ACL gate in handle_challenge rejects non-admitted DIDs (403), preventing challenge completion though not necessarily preventing enumeration or flooding. • credentials: "include" suggests same-origin cookie context, somewhat limiting casual cross-origin abuse.
Recommended Mitigations: Apply per-IP and per-DID rate limiting on /auth/challenge. • Return uniform timing and generic response bodies regardless of ACL admission status to prevent enumeration. • Add monitoring/alerting for high-volume challenge requests from a single source.
🔵 STRIDE-7: Unvalidated Vault Enumeration via listProxyCandidates in Escape-Hatch Flow
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.5 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-200 |
| CAPEC | CAPEC-116 |
| OWASP | A01:2021 - Broken Access Control |
Description: handleChooseIdentity() in Login.tsx allows information disclosure of the operator's full vault-entry list scoped to this VTC due to the retained listProxyCandidates() call path, resulting in exposure of every did-self-issued persona pinned to the site even though the new default flow was designed to avoid this disclosure.
Evidence: vtc-service/admin-ui/src/pages/Login.tsx:~193-225
const handleChooseIdentity = async () => {
setWalletPhase({ kind: "running" });
setCandidates(null);
try {
...
Attack Scenario:
- The PR's own comments state the previous default flow (
listProxyCandidates) was replaced because it "asked the wallet to enumerate every entry pinned to this VTC just to find one, which is a disclosure of the operator's vault." - Despite this,
handleChooseIdentity()in Login.tsx retains the exact same enumeration call as an escape hatch, still reachable via theproxyAvailablesecondary button. - An attacker who can trigger a UI click (e.g., via clickjacking, a compromised admin session, or social engineering an operator to click 'Sign in as a different identity…') still causes full enumeration of the operator's did-self-issued vault entries pinned to this VTC.
- The enumerated
candidateslist is rendered into the DOM (<section className="card"><h3>Pick a proxy identity</h3>...), making all pinned entries visible to anything with DOM read access (malicious extensions, XSS, screen readers/AT with broad permissions). - This defeats the stated privacy goal of the PR for any user who still uses (or is tricked into using) the retained escape hatch.
🔎 Threat Clue: Derived from COMP-002, COMP-003 via EP-005
- Data Flows: handleChooseIdentity -> listProxyCandidates -> candidates state -> DOM render
Preconditions: Operator or an attacker-influenced flow must invoke handleChooseIdentity rather than the new default path., Wallet must expose listProxyCandidates/proxyLogin (older or full-featured builds).
Existing Controls: Escape hatch requires an explicit user click (disabled={busy} guard) and is described in code comments as intentionally consent-gated. • New default flow (loginWithWalletProfile) avoids this disclosure for users who don't use the escape hatch.
Recommended Mitigations: Add a clear, distinct consent dialog explaining the vault-enumeration privacy implication before invoking listProxyCandidates. • Limit displayed candidate metadata to the minimum necessary (e.g., avoid showing unrelated persona attributes). • Consider deprecating the escape hatch entirely once walletProfile capability is broadly available.
🔵 STRIDE-8: Missing Null/Empty Validation on walletProfile Response Fields Enables Malformed-State DoS
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 2.8 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-20,CWE-1287 |
| CAPEC | CAPEC-153 |
| OWASP | A04:2021 - Insecure Design |
Description: loginWithWalletProfile() in wallet.ts allows denial-of-service via malformed extension responses due to shallow validation of only profile.did/profile.entryId truthiness without type or format checks, resulting in unhandled exceptions or confusing failure states if a buggy or malicious extension returns unexpected shapes.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~250-253
if (!profile.did || !profile.entryId) {
throw new Error("wallet returned no identity for this site");
}
Attack Scenario:
-
window.vtaWallet!.walletProfile!({...})is awaited and its result is cast toWalletProfileWireResultvia TypeScript typing only — there is no runtime schema validation. - A malfunctioning or malicious extension could return
{ did: 123, entryId: {}, bound: "yes" }or other type-mismatched values that pass theif (!profile.did || !profile.entryId)truthy check (e.g., a non-empty object is truthy). -
runProxySiop(profile.did, profile.entryId)then passes these malformed values intoJSON.stringify({ did: principalDid })and theproxyLogincall, producing unexpected server-side behavior (e.g., a 400 from/auth/challenge, or worse, being coerced into a valid-looking string that passes basic server checks). - Repeated malformed attempts could be scripted to hammer the login flow with garbage input, and combined with STRIDE-6's lack of rate limiting, contribute to resource exhaustion or noisy error logs obscuring real attacks.
- From the operator's perspective, this manifests as confusing, hard-to-diagnose login failures, degrading availability of the admin login capability itself.
🔎 Threat Clue: Derived from COMP-003 via EP-003
- Data Flows: window.vtaWallet.walletProfile -> loginWithWalletProfile
Preconditions: A buggy or malicious wallet extension build must be installed and reachable as window.vtaWallet., No runtime schema/type validation exists beyond simple truthiness checks.
Existing Controls: Basic truthiness check (if (!profile.did || !profile.entryId)) catches the most common missing-field cases. • Server-side handlers presumably validate did format independently.
Recommended Mitigations: Add explicit typeof profile.did === "string" and typeof profile.entryId === "string" runtime checks before use. • Validate DID format (e.g., did: prefix regex) client-side before sending to /auth/challenge. • Wrap the wallet call in a try/catch that distinguishes malformed-response errors from network/ACL errors for clearer diagnostics.
🟡 STRIDE-9: No Nonce Freshness/Replay Binding Visible in Client-Side runProxySiop Flow
| Field | Detail |
|---|---|
| Category | Tampering, Spoofing |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-294 |
| CAPEC | CAPEC-60 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: runProxySiop() in wallet.ts allows replay-style tampering due to the client passing the server-issued ch.challenge nonce directly into proxyLogin without any visible client-side expiry/one-time-use enforcement, resulting in potential reuse of a captured nonce/id_token if the extension or a man-in-the-middle component replays it before the server invalidates it.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~294-300
const pl = await window.vtaWallet!.proxyLogin!({
entryId,
nonce: ch.challenge,
target: { kind: "did", did: rp },
});
Attack Scenario:
-
runProxySiopfetches a challenge nonce from/auth/challenge(ch.challenge). - The nonce is passed unmodified to
window.vtaWallet!.proxyLogin!({ entryId, nonce: ch.challenge, target }). - If an attacker with DOM/extension-message access captures this nonce and the resulting signed id_token before the legitimate flow completes, and the server's nonce validation is not strictly one-time/short-TTL (not verifiable from client code alone), a replay of the exact same request could succeed a second time.
- Because none of the reviewed client code enforces nonce single-use or TTL client-side (this is presumed to be server-enforced but not confirmed in the provided artifacts), the client offers no defense-in-depth against a compromised extension replaying a captured token.
- A successful replay would let the attacker establish a duplicate authenticated session using a token intended for a single legitimate login.
🔎 Threat Clue: Derived from COMP-003, COMP-004 via EP-001, EP-002
- Data Flows: /auth/challenge response -> proxyLogin nonce param
Preconditions: Attacker must have access to intercept the nonce/id_token exchange (e.g., malicious extension with messaging access, or compromised browser)., Server-side nonce validation must be weaker than strict one-time-use with short TTL (unverified from given code).
Existing Controls: Nonce is server-issued per challenge request, presumably tied to a short-lived session/expiry (server-side, not visible in this diff). • credentials: "include" scopes the challenge request to the authenticated browser session context.
Recommended Mitigations: Confirm and enforce strict one-time-use, short-TTL nonces server-side (out of scope of this diff but critical dependency). • Bind the nonce to a client-generated random value stored only in memory to add a second replay-detection factor. • Add client-side expiry checks so stale challenges are rejected before being sent to proxyLogin.
🔵 STRIDE-10: Refresh Token Defaulted to Empty String Without Explicit Handling
| Field | Detail |
|---|---|
| Category | Denial of Service, Tampering |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 2.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1287,CWE-457 |
| CAPEC | CAPEC-features N/A |
| OWASP | A04:2021 - Insecure Design |
Description: runProxySiop() token-response mapping in wallet.ts allows denial-of-service/session-integrity ambiguity due to refreshToken: tokenResp.tokens.refreshToken ?? "" silently coercing a missing refresh token into an empty string rather than surfacing the absence, resulting in downstream code potentially treating an empty string as a valid (but non-functional) refresh token.
Evidence: vtc-service/admin-ui/src/lib/wallet.ts:~350-354
accessToken: tokenResp.tokens.accessToken,
refreshToken: tokenResp.tokens.refreshToken ?? "",
sessionId: tokenResp.session.id,
holderDid: principalDid,
Attack Scenario:
-
runProxySiopbuilds the finalVtaWalletLoginResultincludingrefreshToken: tokenResp.tokens.refreshToken ?? "". - If the token endpoint omits
refreshToken(e.g., due to a server misconfiguration or an intentionally refresh-less short-lived session), the client silently substitutes an empty string. - Any downstream consumer of
VtaWalletLoginResult.refreshTokenthat checksif (refreshToken)would treat""as falsy and skip refresh logic correctly, but a consumer that checkstypeof refreshToken === "string"orrefreshToken !== undefinedwould incorrectly assume a valid (albeit unusable) refresh token exists. - This could lead to failed silent-refresh attempts being sent to a token-refresh endpoint with an empty token value, generating confusing errors or unnecessarily forcing full re-authentication, degrading availability of the session-continuity feature.
- While low severity, this is a latent footgun for future maintainers extending the refresh-token logic without noticing the empty-string sentinel.
🔎 Threat Clue: Derived from COMP-003 via EP-002
- Data Flows: tokenResp -> VtaWalletLoginResult.refreshToken
Preconditions: Token endpoint must omit refreshToken in its response under some legitimate configuration., Downstream code must not explicitly guard against the empty-string sentinel value.
Existing Controls: Nullish coalescing (?? "") at least prevents undefined from propagating, avoiding a TypeError in simple string-concatenation contexts.
Recommended Mitigations: Use an explicit undefined/null sentinel (or a discriminated union) rather than an empty string to represent "no refresh token issued". • Add a type-level and runtime guard at refresh-token consumption sites that explicitly checks for emptiness before attempting a refresh call. • Log/telemetry-flag sessions issued without a refresh token to detect unexpected server misconfiguration.
🍝 PASTA Threat Model
Application Purpose
The admin-ui provides browser-extension-wallet-based (VTA/SIOP) authentication for VTC administrators, letting operators sign in using a decentralized identity (DID) resolved or chosen from a browser-extension-managed vault, delivering passwordless, self-sovereign-identity-based admin access to the Verifiable Trust Infrastructure.
Inherent Risks
- The security model fundamentally trusts a page-injected browser global (
window.vtaWallet) with no cryptographic proof of extension authenticity. - Client-side TypeScript interface changes (optional vs required fields) can silently widen the acceptable input surface for a privileged authentication flow.
- Admin-facing error messages embed operationally sensitive identifiers (DIDs, CLI commands) directly into UI text.
Objectives
Risk: Limit the blast radius of a compromised or malicious browser extension impersonating window.vtaWallet.; Avoid silent identity-selection ambiguity in a privileged admin authentication path.
Business: Provide a frictionless, passwordless admin sign-in experience using self-sovereign identity wallets.; Reduce onboarding friction for administrators activating a new VTC by removing manual vault-entry configuration.
Security: Ensure only ACL-admitted DIDs can complete authentication as an Admin.; Prevent unauthorized disclosure of the operator's full vault-entry list.; Ensure the identity a session is bound to matches the identity the operator intended and was shown.
Financial: Minimize support costs associated with confusing wallet-vault setup failures during admin onboarding.
Compliance: Maintain auditability of admin identity binding and first-use persona provisioning for access-control reviews.
Functional: Let the wallet resolve or bind the correct persona DID for a given VTC origin without manual configuration.; Preserve a manual entry-selection escape hatch for operators holding multiple personas.
Operational: Ensure the admin login flow remains available and diagnosable when ACL admission is pending.; Keep the wallet-extension integration backward compatible with older extension builds lacking walletProfile.
Business Impact Analysis (1)
BIA-1: Admin Wallet-Based Authentication (Critical)
Administrators authenticate to the VTC admin UI via a browser-extension wallet using SIOP-proxied DID sign-in, gating all administrative access to the Verifiable Trust Infrastructure.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:15 hours
- Stakeholders: Admin Operators / End-User Relying Parties / Security/Compliance Team / VTC Platform Maintainers
- Dependencies: Browser Extension (VTA Wallet) / VTC Admin UI (React SPA) / VTC Auth Service (/auth/challenge, token endpoint) / VTC ACL/Admin Entry Store
-
Disruptions: Malicious extension or spoofed
window.vtaWalletintercepting/forging login flow / ACL misconfiguration blocking legitimate first-time admin sign-ins / Rate-limiting absence enabling challenge-endpoint flooding / Client-server identity-binding mismatch causing wrong-persona sessions - Impacts: Unauthorized administrative access to the Verifiable Trust Infrastructure / Loss of admin ability to sign in, halting platform administration / Disclosure of operator vault contents or DID/administration workflow details / Erosion of trust in the self-sovereign-identity authentication model
Technical Scope
Roles (2): RO-1 VTC Admin · RO-2 Unauthenticated Visitor
Actors (3): AC-1 Admin Operator · AC-2 Browser Extension Process · AC-3 VTC Auth Service
Entry Points (5): EP-1 Wallet Profile Resolution · EP-2 Proxy SIOP Login · EP-3 Auth Challenge Endpoint · EP-4 Proxy Sign-In Primary Button · EP-5 Choose Identity Escape Hatch
Threat Actors (3): TA-1 Malicious Browser Extension Author · TA-2 External Network Attacker · TA-3 Malicious Insider / Social Engineer
Infrastructure (1): IF-1 VTC Web/Application Tier
Trust Boundaries (3): TB-1 Browser Page ↔ Extension Boundary · TB-2 Admin UI ↔ VTC Auth Service Boundary · TB-3 VTC Internal ACL/Admin Store
External Entities (2): EE-1 Admin Operator (Browser User) · EE-2 VTA Browser Extension Vendor/Build
System Components (5): SC-1 Admin UI Login Page · SC-2 Wallet Client Library · SC-3 Browser Extension (VTA Wallet) · SC-4 VTC Auth Service · SC-5 VTC ACL/Admin Entry Store
Resources And Assets (3): RA-1 Persona DID and Vault Entry Metadata · RA-2 Access/Refresh Tokens and Session ID · RA-3 ACL Admin Entries
Technologies And Dependencies (3): TD-1 React · TD-2 TypeScript · TD-3 window.vtaWallet Browser Extension API
Use Cases (2)
- Admin Sign-In via Wallet-Resolved Persona: An administrator clicks the primary sign-in button; the wallet extension resolves or binds the persona DID it knows for this VTC, and the SIOP proxy round-trip completes to establish an authenticated
- Admin Sign-In via Manual Identity Selection: An administrator holding multiple personas clicks the secondary 'choose identity' button, enumerates the vault entries pinned to this VTC, selects one, and completes the SIOP proxy round-trip as that
📋 Risk Registry (7)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Unauthenticated browser global spoofing enables full admin-login impersonation | High | High | Immediate | High |
| RISK-002 | Absence of rate limiting on the unauthenticated challenge endpoint enables DID enumeration and flooding | Medium | Medium | Short-Term | Medium |
| RISK-003 | No cryptographic binding confirms the authenticated session matches the DID shown to the operator | Medium | Low | Short-Term | Medium |
| RISK-004 | Sensitive DID and admin CLI command text exposed in client-rendered error messages | Medium | Low | Short-Term | Low |
| RISK-005 | Retained vault-enumeration escape hatch undermines the PR's stated privacy goal | Low | Low | Medium-Term | Low |
| RISK-006 | Type-level relaxation of entryId from required to optional widens future misuse surface | Medium | Low | Short-Term | Low |
| RISK-007 | Silent primary-button behavior change removes explicit identity confirmation from the default admin login path | Low | Low | Medium-Term | Low |
⚔️ Attack Scenarios (3)
SC-2: Wallet Client Library
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Malicious Browser Extension Author<br><i>Impersonate the legitimate wallet to hijack admin auth</i>" }
TA2@{ shape: rect, label: "TA-2: External Network Attacker<br><i>Enumerate DIDs and disrupt availability</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Extension-Origin Spoofing via window.vtaWallet<br><i>High / Likely</i>" }
S4@{ shape: rect, label: "STRIDE-4: Missing DID Consistency Check<br><i>Medium / Possible</i>" }
S9@{ shape: rect, label: "STRIDE-9: No Nonce Freshness Binding<br><i>Medium / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C1@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
C2@{ shape: rect, label: "CAPEC-194: Fake the Source of Data" }
C3@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
C4@{ shape: rect, label: "CAPEC-60: Reusing Session IDs" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W1@{ shape: rect, label: "CWE-346: Origin Validation Error" }
W2@{ shape: rect, label: "CWE-829: Inclusion of Untrusted Functionality" }
W3@{ shape: rect, label: "CWE-367: TOCTOU Race Condition" }
W4@{ shape: rect, label: "CWE-294: Authentication Bypass by Capture-Replay" }
end
subgraph SL5["5. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: Wallet Client Library" }
end
TA1 --> S1
TA1 --> S4
TA2 --> S9
S1 --> C1
S1 --> C2
S4 --> C3
S9 --> C4
C1 --> W1
C2 --> W2
C3 --> W3
C4 --> W4
W1 --> SC2
W2 --> SC2
W3 --> SC2
W4 --> SC2
linkStyle 0 stroke:#FF0000,stroke-width:2px
linkStyle 1 stroke:#FF0000,stroke-width:2px
linkStyle 2 stroke:#FFA500,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#FF0000,stroke-width:2px
linkStyle 5 stroke:#FF0000,stroke-width:2px
linkStyle 6 stroke:#FFA500,stroke-width:2px
linkStyle 7 stroke:#FFA500,stroke-width:2px
linkStyle 8 stroke:#FF0000,stroke-width:2px
linkStyle 9 stroke:#FF0000,stroke-width:2px
linkStyle 10 stroke:#FFA500,stroke-width:2px
linkStyle 11 stroke:#FFA500,stroke-width:2px
SC-4: VTC Auth Service
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA2@{ shape: rect, label: "TA-2: External Network Attacker<br><i>Enumerate DIDs and disrupt availability</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S6@{ shape: rect, label: "STRIDE-6: No Rate Limiting on /auth/challenge<br><i>Medium / Likely</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C5@{ shape: rect, label: "CAPEC-125: Flooding" }
C6@{ shape: rect, label: "CAPEC-112: Brute Force" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W5@{ shape: rect, label: "CWE-307: Improper Restriction of Excessive Authentication Attempts" }
W6@{ shape: rect, label: "CWE-799: Improper Control of Interaction Frequency" }
end
subgraph SL5["5. System Component"]
direction LR
SC4@{ shape: rect, label: "SC-4: VTC Auth Service" }
end
TA2 --> S6
S6 --> C5
S6 --> C6
C5 --> W5
C6 --> W6
W5 --> SC4
W6 --> SC4
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:#FFA500,stroke-width:2px
SC-1: Admin UI Login Page
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. Threat Actors"]
direction LR
TA3@{ shape: rect, label: "TA-3: Malicious Insider / Social Engineer<br><i>Leverage disclosed DID and CLI command</i>" }
end
subgraph SL2["2. Threats"]
direction LR
S3@{ shape: rect, label: "STRIDE-3: DID and Admin CLI Disclosure in Error Message<br><i>Medium / Very Likely</i>" }
S5@{ shape: rect, label: "STRIDE-5: Default-to-Profile-Path UI Change<br><i>Low / Possible</i>" }
S7@{ shape: rect, label: "STRIDE-7: Unvalidated Vault Enumeration Escape Hatch<br><i>Low / Possible</i>" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
C7@{ shape: rect, label: "CAPEC-116: Excavation" }
C8@{ shape: rect, label: "CAPEC-141: Cache Poisoning (UI Trust Confusion)" }
end
subgraph SL4["4. Weaknesses"]
direction LR
W7@{ shape: rect, label: "CWE-209: Generation of Error Message with Sensitive Information" }
W8@{ shape: rect, label: "CWE-778: Insufficient Logging" }
W9@{ shape: rect, label: "CWE-200: Exposure of Sensitive Information" }
end
subgraph SL5["5. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: Admin UI Login Page" }
end
TA3 --> S3
TA3 --> S5
TA3 --> S7
S3 --> C7
S5 --> C8
S7 --> C7
C7 --> W7
C8 --> W8
C7 --> W9
W7 --> SC1
W8 --> SC1
W9 --> SC1
linkStyle 0 stroke:#FFA500,stroke-width:2px
linkStyle 1 stroke:#00FF00,stroke-width:2px
linkStyle 2 stroke:#00FF00,stroke-width:2px
linkStyle 3 stroke:#FFA500,stroke-width:2px
linkStyle 4 stroke:#00FF00,stroke-width:2px
linkStyle 5 stroke:#00FF00,stroke-width:2px
linkStyle 6 stroke:#FFA500,stroke-width:2px
linkStyle 7 stroke:#00FF00,stroke-width:2px
linkStyle 8 stroke:#00FF00,stroke-width:2px
📊 Risk Summary
Total Threats: 10
By Severity: Low: 4 · High: 1 · Medium: 5
By Category: Spoofing: 3 · Tampering: 5 · Information Disclosure: 4 · Elevation of Privilege: 1 · Repudiation: 1 · Denial of Service: 3
🎯 Attack Surface
Kill Chain 1: An attacker who can plant or substitute a malicious browser extension (or inject script that defines a fake window.vtaWallet before the real extension loads) exploits the unauthenticated, unauthenticated-origin trust boundary (TB-1) identified in STRIDE-1 to fully impersonate the wallet's walletProfile and proxyLogin capabilities; because isWalletProfileAvailable() performs only a typeof function check with no cryptographic attestation, the attacker's fabricated profile.did/profile.entryId flow straight into runProxySiop(), reaching the real /auth/challenge endpoint (STRIDE-6, no rate limiting or enumeration protection) and ultimately either exhausting the challenge endpoint or, if the attacker also controls response minting, completing an authenticated session as an attacker-chosen identity — this is the highest-impact chain because it collapses the entire wallet-trust model into a single unauthenticated JavaScript-global check. Kill Chain 2: A secondary, lower-effort chain combines STRIDE-4 (no cryptographic binding between the walletProfile-resolved DID and the DID that ultimately authenticates) with STRIDE-9 (no visible nonce replay protection on the client) — an attacker positioned to observe or race the extension's internal state between the walletProfile and proxyLogin calls could cause the session to be established under a DID different from the one displayed to the operator, and if the underlying nonce is not strictly one-time-use server-side, a captured id_token/nonce pair could be replayed to duplicate a session, compounding the trust confusion from Kill Chain 1. Kill Chain 3: A social-engineering-oriented chain starts from STRIDE-3, where a rejected first-time sign-in leaks the operator's exact DID and the precise vtc admin invite --did <DID> command into client-rendered UI text; combined with STRIDE-7's retained vault-enumeration escape hatch (which still discloses every did-self-issued entry pinned to the VTC when used), an attacker with any DOM-read access (malicious extension, XSS elsewhere in the app, or a captured screenshot) gains both the target DID and the exact remediation command needed to social-engineer a legitimate admin into inviting an attacker-controlled identity onto the ACL — turning an information-disclosure bug into a path toward Kill Chain 1's ultimate goal of unauthorized admin access.
🛡️ Risk Mitigation Strategy
Priority 1 (Immediate): The single largest control gap is the complete absence of authenticity verification for the window.vtaWallet global (RISK-001/STRIDE-1) — this must be addressed before this PR's new default-trust behavior (auto-resolving personas via walletProfile) ships broadly, since it silently increases reliance on an unauthenticated browser-injected object without any equivalent hardening of that trust boundary; recommended immediate action is to require extension-signed attestation or migrate to postMessage-based origin-checked messaging, paired with a clear rollback plan if extension vendors cannot support attestation in the short term. Priority 2 (Short-Term): Close the identity-binding gap between what is displayed to the operator and what actually authenticates (RISK-003/STRIDE-4, STRIDE-9) by adding explicit client-side verification that the token-exchange response's bound DID matches profile.did, and confirm server-side nonce single-use/TTL enforcement; simultaneously implement rate limiting and response normalization on /auth/challenge (RISK-002/STRIDE-6) to close the DID-enumeration and flooding vector, and revert the entryId interface field to required with an explicit runtime assertion (RISK-006/STRIDE-2) to prevent future ambiguous-default misuse. Priority 3 (Short-to-Medium-Term): Remediate the information-disclosure findings by redacting DIDs and admin CLI commands from client-rendered error text (RISK-004/STRIDE-3), adding an explicit consent/privacy dialog to the retained vault-enumeration escape hatch (RISK-005/STRIDE-7), and introducing a first-use confirmation step that shows the resolved persona DID before finalizing loginWithWalletProfile() so operators can catch unintended default-identity selection (RISK-007/STRIDE-5) — together these changes preserve the PR's stated UX and privacy goals while closing the residual disclosure and confirmation gaps its own refactor introduced.
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 2 | 1 |
Confirmed (2)
- 🟡 Sensitive DID and privileged CLI command disclosed in client-rendered error message
- 🟡 Client accepts holderDid from caller input without verifying it against the signed proxy-login result (TOCTOU / missing consistency check)
Must-Review-By-Human (1)
- 🟠 Unauthenticated trust of window.vtaWallet global enables extension/identity spoofing (Spoofing / Broken AuthN)
The dead end
Proxy sign-in began by asking the wallet to enumerate every vault entry pinned to this VTC, so it could find one:
That is a disclosure of the operator's vault to answer a question about a single entry, and it costs its own consent prompt to make. And on a wallet with nothing pinned here it returns an empty array, at which point the page gave up:
A setup step in another application, quoted at someone who was trying to sign in.
The change
The wallet owns that question now:
walletProfileresolves the entry bound to this origin, or asks the operator which identity to use and remembers the answer. It mints nothing.The DID has to be known before the challenge, because
handle_challengebinds the nonce to it — which is why this is two calls and not one, and whyproxyLoginalone could never have reached it.Round trips are unchanged. The profile call replaces the vault listing one for one, and passing
entryIdback meansproxyLogindoes not repeat the lookup. One fewer consent prompt in the steady state, and the page no longer sees the rest of the vault.A first sign-in says which DID needs admitting
The persona was created a moment ago, so this VTC has never seen it and
handle_challengerefuses on the ACL gate — a 403 an operator cannot act on.bound: truetells us it was the first time, so the message names the DID and the fix:Choosing among several identities is kept
An operator may hold both an Admin and a member persona here, and the wallet returns the one bound to this origin. "Sign in as a different identity…" still runs the old enumeration — behind a click, because reaching it discloses the vault to this page.
It is also the only proxy route on a wallet build that predates
walletProfile, which is whatisWalletProfileAvailable()distinguishes: a capability probe that produces an accurate message instead of aTypeError, not a compatibility fold.loginWithWalletProxynow delegates to a sharedrunProxySiop, so the wallet-resolved and hand-picked paths apply exactly the same rule and the same error handling.Coordination
Requires OpenVTC/vta-browser-plugin#145.
proxyLoginandvaultListare untouched there, so this can land in either order — the button degrades to the picker until the wallet ships. Sibling change: affinidi/affinidi-webvh-service#177.Checks
tsc -b --noEmitcleannpm run buildcleanhandle_challenge, the ACL gate and the auth routes are untouched