Skip to content

fix(vtc-admin): let the wallet choose the identity, instead of reading the vault - #1212

Merged
stormer78 merged 1 commit into
mainfrom
feat/wallet-profile-resolution
Aug 30, 2026
Merged

fix(vtc-admin): let the wallet choose the identity, instead of reading the vault#1212
stormer78 merged 1 commit into
mainfrom
feat/wallet-profile-resolution

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

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:

vaultList({ targetDid, secretKind: "didSelfIssued" })

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:

SIGN-IN FAILED — 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 change

The wallet owns that question now:

const { did, entryId } = await walletProfile({ target });
const { challenge }    = await POST /auth/challenge { did };
await proxyLogin({ entryId, nonce: challenge, target });

walletProfile resolves 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_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, 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_challenge refuses on the ACL gate — a 403 an operator cannot act on. bound: true tells us it was the first time, so the message names the DID and the fix:

This was the first sign-in as did:webvh:…. 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 did:webvh:….

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 what isWalletProfileAvailable() distinguishes: a capability probe that produces an accurate message instead of a TypeError, not a compatibility fold.

loginWithWalletProxy now delegates to a shared runProxySiop, 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. proxyLogin and vaultList are 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 --noEmit clean
  • npm run build clean
  • No daemon-side change — handle_challenge, the ACL gate and the auth routes are untouched

…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>
@stormer78
stormer78 requested a review from a team as a code owner August 30, 2026 20:54
@stormer78
stormer78 merged commit 543babd into main Aug 30, 2026
13 checks passed
@stormer78
stormer78 deleted the feat/wallet-profile-resolution branch August 30, 2026 21:02
stormer78 added a commit that referenced this pull request Aug 30, 2026
`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>
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Aug 30, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

2 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

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch feat/wallet-profile-resolutionmain
Validated 2026-09-05
Scan ID db80819b
Validator AI Security Validation Agent

🗺️ Scan Coverage

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

Module Files scanned Findings
vtc-service 2 5

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 2 1

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


🔒 Security Issues

Confirmed Vulnerabilities (2)

🟡 Sensitive DID and privileged CLI command disclosed in client-rendered error message

Field Detail
Severity MEDIUM
Location vtc-service/admin-ui/src/lib/wallet.ts:255
Finding ID github_pr-a9d2bac0557e
CWE CWE-209, CWE-200
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1552, T1589
CAPEC CAPEC-116
CVSS 4.0 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)
DREAD 4.6
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • Confirmed reachable via a public, unauthenticated path (EP-004 → EP-001 → DOM render), which supports maintaining rather than downgrading severity. However, this is a passive info-disclosure issue (DID + CLI command text exposure) with no direct exploit payload, no privilege escalation, and no data beyond what the triggering user's own session displays — exploitability and direct technical impact are low, keeping this at medium rather than escalating to high/critical. There is also no CVSS score or confirmed exploit-in-the-wild evidence to justify escalation.
  • Composite score: 5.5
  • Environment: production

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.

  try {
    return await runProxySiop(profile.did, profile.entryId);
  } catch (err) {
    if (!profile.bound) throw err;
    // The persona was created a moment ago, so this VTC has never seen it and
    // the ACL gate in `handle_challenge` is by far the likeliest cause. Say
    // which DID needs admitting: the operator cannot act on a 403 alone, and
    // the DID is not otherwise on screen anywhere.
    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}\`.`,
    );
  }
}

Vulnerable lines: 237, 264

🔎 Evidence: vtc-service/admin-ui/src/lib/wallet.ts:255

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}\`.`,
);

💥 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:

  • Network exposure: public
  • Auth barrier: none
  • Attack path: EP-004 (Login button) → handleProxyStart → loginWithWalletProfile → runProxySiop fails ACL check at /auth/challenge (EP-001) → catch block builds message → Login.tsx renders walletPhase.message in DOM

⚖️ Triage Factors:

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

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:

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

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:

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}\`.`,
);

Secure code:

const message = err instanceof Error ? err.message : String(err);
// Log full detail server-side / to a secured audit channel, not into the
// client-visible Error object.
auditLog.warn("proxy-login-first-bind-rejected", { did: profile.did, message });
throw new Error(
  `${message}\n\nThis identity has not yet been admitted to this VTC. " +
    "Ask an administrator to review the pending sign-in request and add " +
    "the identity via the admin console.`,
);

Additional recommendations:

  • If DID display is required for operator usability, truncate/redact it (e.g., show only first/last 6 chars) with a 'reveal' action.
  • Avoid teaching users the exact admin CLI syntax in UI text; link to documentation instead.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: In loginWithWalletProfile() catch block: 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}`.); This error is thrown and, per description, rendered into the DOM via Login.tsx's walletPhase.message. EVIDENCE NOT FOUND: No redaction/truncation logic
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Client accepts holderDid from caller input without verifying it against the signed proxy-login result (TOCTOU / missing consistency check)

Field Detail
Severity MEDIUM
Location vtc-service/admin-ui/src/lib/wallet.ts:296
Finding ID github_pr-3455231ea4ee
CWE CWE-367, CWE-863, CWE-345
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1550
CAPEC CAPEC-26
CVSS 4.0 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)
Reachability 🔴 Reachable
Exploit Maturity conceptual
Detection Source skill_scan

🧠 AI Triage:

  • Triaged severity: MEDIUM
  • CVSS/CWE data is unavailable (first-party design flaw, not a CVE), but the scanner's own analysis and code evidence support medium: reachable via a public endpoint with basic auth, but exploitability is explicitly rated low because it requires either a race condition or a compromised wallet extension (a separate precondition/finding). Exploit maturity is conceptual with no public PoC. This does not meet high/critical gates (Exploitability ≥5 or PubExploit ≥7) since the scanner explicitly states exploitability is low and no public exploit exists — severity remains medium.
  • Composite score: 5.3
  • Environment: production

🔎 Evidence: vtc-service/admin-ui/src/lib/wallet.ts:296

return {
    accessToken: tokenResp.tokens.accessToken,
    refreshToken: tokenResp.tokens.refreshToken ?? "",
    sessionId: tokenResp.session.id,
    holderDid: principalDid,
};

💥 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:

  • Network exposure: public
  • Auth barrier: basic
  • Attack path: EP-003 (walletProfile) → loginWithWalletProfile → runProxySiop(principalDid, entryId) at wallet.ts:296-302 → returned holderDid trusted by Login.tsx finishWithBearer without cross-check

⚖️ Triage Factors:

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

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:

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

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:

return {
    accessToken: tokenResp.tokens.accessToken,
    refreshToken: tokenResp.tokens.refreshToken ?? "",
    sessionId: tokenResp.session.id,
    holderDid: principalDid,
};

Secure code:

// Re-derive the authenticated DID from the server-issued/verified token
// rather than trusting the value that was merely requested.
const verifiedDid = tokenResp.session?.subjectDid ?? decodeAndVerifyIdTokenSubject(pl.idToken);
if (verifiedDid !== principalDid) {
  throw new Error(
    `Identity mismatch: requested ${principalDid} but session was issued for ${verifiedDid}.`,
  );
}
return {
    accessToken: tokenResp.tokens.accessToken,
    refreshToken: tokenResp.tokens.refreshToken ?? "",
    sessionId: tokenResp.session.id,
    holderDid: verifiedDid,
};

Additional recommendations:

  • Ensure the server-side token endpoint itself validates the SIOP id_token signature and binds the session to the token's subject, independent of what the client claims.
  • Surface an explicit alert/log event on any client-observed DID mismatch for security monitoring.

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: runProxySiop returns { accessToken: tokenResp.tokens.accessToken, refreshToken: tokenResp.tokens.refreshToken ?? "", sessionId: tokenResp.session.id, holderDid: principalDid };principalDid is the parameter passed IN to the function, not derived from tokenResp or the id_token's sub claim. The function signature async function runProxySiop(principalDid: string, entryId: string): Promise<VtaWalletLoginResult> confirms this is the input parameter being echoed back verb
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

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

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.

🟠 Unauthenticated trust of window.vtaWallet global enables extension/identity spoofing (Spoofing / Broken AuthN)

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"); } and isWalletProxyAvailable similarly only checks typeof window.vtaWallet?.walletProfile === "function" etc. There is no origin pinning, no signature/attestation check on window.vtaWallet before trusting walletProfile()'s returned did/entryId, which then flow into runProxySiop(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.

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

🛡️ Threat Model & Affect Analysis — PR #1212

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch feat/wallet-profile-resolutionmain
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:

  1. Attacker delivers a malicious script into the admin-ui origin via a separate XSS, malicious browser extension, or compromised dependency.
  2. Malicious script defines window.vtaWallet = { walletProfile: async () => ({ did: attackerDid, entryId: attackerEntryId, bound: true }) } before the legitimate extension loads or by overwriting the property.
  3. isWalletProfileAvailable() in wallet.ts only checks typeof window.vtaWallet?.walletProfile === "function", so the spoofed object passes the capability probe.
  4. loginWithWalletProfile() calls window.vtaWallet!.walletProfile!({...}) and trusts the returned profile.did and profile.entryId unconditionally.
  5. runProxySiop(profile.did, profile.entryId) sends { did: principalDid } to /auth/challenge (EP-001) and then calls window.vtaWallet!.proxyLogin!({ entryId, nonce, target }), both of which are attacker-controlled if the attacker fully controls the fake vtaWallet.
  6. 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:

  1. The diff changes proxyLogin?(params: { entryId: string; ... }) to proxyLogin?(params: { entryId?: string; ... }) in the VtaWalletProvider interface (wallet.ts lines ~84-95).
  2. runProxySiop() is now the single call site that always supplies entryId, but the type now permits any future or third-party caller to omit it.
  3. A malformed or malicious caller path (e.g., a future code change, or a compromised dependency invoking window.vtaWallet.proxyLogin directly) can invoke proxyLogin({ nonce, target }) without entryId.
  4. If the underlying browser-extension implementation falls back to a default/last-used vault entry when entryId is omitted, this silently authenticates as an unintended persona.
  5. 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 holderDid matches 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:

  1. Operator attempts first-time sign-in via loginWithWalletProfile(); the wallet binds a brand-new persona and sets profile.bound = true.
  2. runProxySiop(profile.did, profile.entryId) fails because the VTC's /auth/challenge ACL gate rejects the unknown DID (403).
  3. 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}\`.`.
  4. 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.
  5. 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:

  1. window.vtaWallet!.walletProfile!({...}) returns { did, entryId, bound } (wallet.ts) which the code treats as authoritative for which persona will be used.
  2. 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 entry entryId maps to.
  3. runProxySiop sends { did: principalDid } to /auth/challenge and separately calls proxyLogin({ 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.
  4. VtaWalletLoginResult returned includes holderDid: 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.
  5. finishWithBearer(result.accessToken) (Login.tsx) accepts the resulting access token without the UI layer re-validating that the authenticated identity matches profile.did shown 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:

  1. Before the diff, the primary button was gated on proxyAvailable and invoked handleProxyStart bound to the entry-listing/-choosing flow.
  2. After the diff, the same visual primary button position is now gated on profileAvailable and silently calls loginWithWalletProfile() instead — a materially different trust decision (wallet auto-resolves identity vs. operator explicitly picks it).
  3. 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.
  4. 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.
  5. 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:

  1. runProxySiop() in wallet.ts POSTs { did: principalDid } to ${base}/auth/challenge with credentials: "include" but no visible authentication requirement (EP-001 marked auth_required: false in recon).
  2. An attacker crafts direct HTTP requests to /auth/challenge with a large volume of distinct or repeated did values, bypassing the UI entirely.
  3. 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.
  4. 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.
  5. This directly weaponizes the same code path introduced/modified by this PR (runProxySiop's /auth/challenge call), now reachable via two client flows (loginWithWalletProfile and loginWithWalletProxy) 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:

  1. 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."
  2. Despite this, handleChooseIdentity() in Login.tsx retains the exact same enumeration call as an escape hatch, still reachable via the proxyAvailable secondary button.
  3. 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.
  4. The enumerated candidates list 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).
  5. 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:

  1. window.vtaWallet!.walletProfile!({...}) is awaited and its result is cast to WalletProfileWireResult via TypeScript typing only — there is no runtime schema validation.
  2. A malfunctioning or malicious extension could return { did: 123, entryId: {}, bound: "yes" } or other type-mismatched values that pass the if (!profile.did || !profile.entryId) truthy check (e.g., a non-empty object is truthy).
  3. runProxySiop(profile.did, profile.entryId) then passes these malformed values into JSON.stringify({ did: principalDid }) and the proxyLogin call, 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).
  4. 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.
  5. 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:

  1. runProxySiop fetches a challenge nonce from /auth/challenge (ch.challenge).
  2. The nonce is passed unmodified to window.vtaWallet!.proxyLogin!({ entryId, nonce: ch.challenge, target }).
  3. 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.
  4. 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.
  5. 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:

  1. runProxySiop builds the final VtaWalletLoginResult including refreshToken: tokenResp.tokens.refreshToken ?? "".
  2. 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.
  3. Any downstream consumer of VtaWalletLoginResult.refreshToken that checks if (refreshToken) would treat "" as falsy and skip refresh logic correctly, but a consumer that checks typeof refreshToken === "string" or refreshToken !== undefined would incorrectly assume a valid (albeit unusable) refresh token exists.
  4. 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.
  5. 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.vtaWallet intercepting/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
Loading

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
Loading

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
Loading

📊 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 ⚠️ Must-Review-By-Human
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)

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