feat(rp-login): login() and loginDidcomm() sign in as the per-site persona - #146
Conversation
`login()` — the REST SIOPv2 path, and the one an RP page reaches for first — signed as the wallet's holder DID for **every site**, unconditionally and with no signal. It read no vault entry and took no parameter that could change that, while `signin-flow.tsx` told the operator, on screen, that "each site gets its own identity" and "a site sees an identity you use only there — never your wallet's own address". That claim was true of `proxyLogin` and false of the button next to it. Now the identity is resolved per origin: a bound persona if this site has one, the wallet's own identity if the operator chose it for this site, and otherwise a prompt that asks. The explainer becomes true. **The holder stays as an offered answer, not a silent fallback.** The RP's ACL is checked against whichever DID signs in, and every enrolment made before personas existed names the holder — `vtc admin invite --did <your-did>` puts it there. Dropping the route would break those sites on the next sign-in with an RP refusal as the only clue. So the picker lists "My wallet's own identity" last, labelled with what it costs (every site holding it can recognise the same person), and the choice is remembered per origin in `site-identity.ts`. It is offered **only** where it can be honoured. `login()` self-issues from a key the browser holds; `proxyLogin` and `walletProfile` mint through a vault entry at the VTA, and the holder is not one — so `allowHolder` gates the option rather than presenting a choice that fails after it is made. **A persona always beats a recorded holder choice.** It is the more specific statement about the site and the one the operator can see and revoke in the vault; reading the local record first would let a stale choice mask an entry bound later through `proxyLogin`, and the sign-in would use an identity the vault contradicts. Binding a persona clears the record, so there is never a second answer sitting behind the first. The id_token source is now a parameter rather than a branch: `SiopIdTokenMinter` with `selfIssuedMinter()` for the holder and a VTA-backed one in the offscreen for a persona. That keeps `siop` at its layer — the persona mint needs `vault`, which is two layers up — and `loginViaSiop` stops taking a `SigningIdentity` it can only use one way. `minter.did` is load-bearing beyond the signature: the challenge is requested for it, and the RP refuses unless the DID it issued the challenge to is the one that signed (`session.did != input.signer_did` in vti-common's `handle_authenticate`). The persona minter therefore reads `principalDid` back from the entry rather than assuming it — it is maintainer-derived, and an entry rotated at the VTA signs as something the wallet never chose. `holderDid` in the result is now the DID that actually signed. Reporting the wallet's own for a persona login would tell the page it is talking to an identity that signed nothing in that flow. `loginDidcomm` is unchanged and still authenticates as the holder; it needs the channel to sign as a persona, which is the next commit. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The other half. `loginDidcomm` authenticates by the **proof on the
document**, and the channel puts that proof there — so signing in as a
per-site persona meant a channel that could sign as one, and a persona's
key never leaves the VTA.
`signOutboundTask` now takes a `TaskSigner` — `{ did, sign(envelope) }` —
instead of a private key. Two implementations: `localTaskSigner` for the
holder's own key, and `vaultTaskSigner`, which asks the VTA to sign via
`vault/sign-trust-task/0.2`. The channel cannot tell them apart, and
neither can the RP: it verifies a proof against a DID and never learns
where the bytes were produced.
**The signer stays REQUIRED.** Widening the type does not weaken the
rule CLAUDE.md records — a channel that could be built without one would
still silently send unsigned documents. What changed is only where the
key is. Channel options accept `SigningIdentity | TaskSigner` and
normalise once at construction, because the fourteen deprecated `*Rest`
helpers thread an identity straight through their own public options
types and rewriting all of them would say something none of their
callers need to know.
**Transport sender and document signer are now separate, because the RP
treats them as separate.** `handle_authenticate` establishes the caller
from the signature (`session.did != input.signer_did`), not from who
delivered the frame. So a persona login rides the wallet's own transport
— there is no second mediator session, and the persona has no local key
to open one — while the documents are issued by and signed as the
persona. `SessionIdentity` gains `documentSigner` rather than widening
`signing`, which would have handed a keyless signer to
`tspHolderIdentityFromSecret` and failed far from the cause.
**One identity across both steps.** `loginViaTrustTask` takes `subject`
and uses it for the challenge AND the authenticate. Requesting the
challenge for the persona and issuing the authenticate as the holder
would be refused by the RP on the signer check — after the operator had
already approved the sign-in — so a test pins both envelopes, not just
the first.
The `signing.did !== holder.did` guard is gone from `loginViaTrustTask`.
It could not survive a channel signing as a persona, and it was checking
the wrong thing anyway: that function never signs. The rule it stood for
is enforced where the signature happens, on every outbound document, by
`signOutboundTask`'s existing issuer check. Its test now pins the
behaviour that replaced it rather than the guard that moved.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review2 findings need a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #146
🗺️ Scan CoverageModules scanned: 2 · with findings: 2 · files: 18 · findings: 3
Executive Summary
🔒 Security Issues
|
| Field | Detail |
|---|---|
| Severity | LOW |
| Location | packages/extension/src/offscreen.ts:629 |
| Finding ID | github_pr-71cc68495d27 |
| OWASP | A01:2021 - Broken Access Control |
| CVSS 4.0 | 3.5 |
| Exploit Maturity | conceptual |
| Detection Source | mcp_semgrep |
Summary: Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co — 3 occurrence(s): offscreen.ts:629, offscreen.ts:1201, offscreen.ts:1210
📝 Description:
Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co
🌱 Root Cause: Unsafe Formatstring
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Priority: Short-term
Unsafe Formatstring: Detected string concatenation with a non-literal variable in a util.format / console.log function. If an attacker injects a format specifier in the string, it will forge the log message. Try to use co
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 85%
- AI Validation Evidence: EVIDENCE FOUND: The evidence code_snippet is empty (''), and offscreen.ts is not included in source_files, so no concrete format-string/log-injection sink could be located or quoted. EVIDENCE NOT FOUND: packages/extension/src/offscreen.ts is absent from source_files entirely; no util.format/console.log call or tainted variable concatenation could be verified at line 629 or elsewhere. CHANGED VS PRE-EXISTING: offscreen.ts is not in the visible diff/changed-file list provided in this context, and
- 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.
🟡 SigningIdentity/ChannelSigner abstraction moves trust boundary to remote VTA without validating persona binding before session/document construction
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | packages/core/src/vault/task-signer.ts:1 |
| Finding ID | github_pr-2023b3bfbbd4 |
| CWE | CWE-345 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
| Detection Source | threat_model |
📝 Description:
vaultTaskSigner delegates signing of a document destined for a relying party to a remote VTA session, and only checks that a proof field is present on the returned envelope — it does not verify that the returned proof actually verifies against opts.did, nor that the signedEnvelope's issuer/other fields were not tampered with by the VTA response before mutating the original envelope in place.
🌱 Root Cause: The signer trusts the shape of the VTA's response (presence of proof) without cryptographically or structurally validating that the proof corresponds to the expected DID or that other envelope fields were not altered in transit/by the VTA.
🔎 Evidence: packages/core/src/vault/task-signer.ts:1
export function vaultTaskSigner(opts: VaultTaskSignerOptions): TaskSigner {
return {
did: opts.did,
sign: async (envelope) => {
const { signedEnvelope } = await vaultSignTrustTask(opts.session, {
holder: opts.holder,
service: opts.service,
entryId: opts.entryId,
unsignedEnvelope: envelope as unknown as Record<string, unknown>,
});
const proof = (signedEnvelope as { proof?: unknown }).proof;
if (!proof) {
throw new Error(
`vault/sign-trust-task: the VTA returned an envelope with no proof for ${opts.did}`,
);
}
(envelope as { proof?: unknown }).proof = proof;
},
};
}
🎯 Attack Scenario:
A compromised or malicious VTA session returns a signedEnvelope with a proof object that is malformed, belongs to a different key, or the VTA silently changes envelope fields (like issuer) before returning it; since only truthiness of proof is checked here, the tampered envelope is used and dispatched to the RP, which independently verifies the proof — but the local code has weakened defense-in-depth by trusting the VTA response blindly for structural validation.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 50%
- AI Validation Evidence: EVIDENCE FOUND: vaultTaskSigner in task-signer.ts only checks
if (!proof) { throw ... }before assigning(envelope as { proof?: unknown }).proof = proof;— no validation that the proof's verificationMethod DID matches opts.did. The doc comment claims 'The VTA refuses with envelope_issuer_mismatch when the envelope's issuer is not the entry's principalDid... which signOutboundTask checks before calling this' implying signOutboundTask enforces envelope.issuer === signer.did (opts.did) prior to- 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 #146
| Field | Value |
|---|---|
| Repository | OpenVTC/vta-browser-plugin |
| Branch | feat/per-site-persona-login → 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 per-site persona login support: loginViaTrustTask() and loginViaSiop() can now authenticate as a DID other than the wallet's holder identity, with the persona's private key held remotely at a VTA vault and never exposed to the browser. This required replacing concrete SigningIdentity types with pluggable TaskSigner/ChannelSigner/SiopIdTokenMinter abstractions across the signing/transport stack, and removing a hard local invariant check that previously guaranteed signer==holder.
Diff: +245 / -32 lines
Types: security, feature, refactor
📁 File Classifications
packages/core/src/rp-login/trust-task.ts
- Type: security
packages/core/src/siop/login-client.ts
- Type: security
packages/core/src/vault/index.ts
- Type: config
packages/core/src/vault/task-signer.ts
- Type: security
packages/core/src/vta/auth-tasks.ts
- Type: security
packages/core/src/vta/didcomm.ts
- Type: security
🛡️ STRIDE Threat Model
Identified Threats (10)
🔴 STRIDE-1: Persona Impersonation via Unverified entryId-to-DID Binding in vaultTaskSigner
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Elevation of Privilege |
| Severity | Critical |
| Likelihood | Likely |
| CVSS | 8.7 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-346,CWE-863,CWE-345 |
| CAPEC | CAPEC-151,CAPEC-196 |
| OWASP | A01:2021 - Broken Access Control, A07:2021 - Identification and Authentication Failures |
Description: vaultTaskSigner in packages/core/src/vault/task-signer.ts allows signing as any persona DID due to missing local verification that the supplied entryId's principalDid matches the caller-supplied did before dispatch, resulting in cross-persona impersonation if the VTA-side check is bypassed or misconfigured.
Evidence: packages/core/src/vault/task-signer.ts:48-77
export function vaultTaskSigner(opts: VaultTaskSignerOptions): TaskSigner {
return {
did: opts.did,
sign: async (envelope) => {
const { signedEnvelope } = await vaultSignTrustTask(opts.session, {
holder: opts.holder,
service: opts.service,
entryId: opts.entryI
Attack Scenario:
- Attacker who controls or compromises a caller of vaultTaskSigner (e.g. malicious extension code, compromised background.ts) supplies an entryId belonging to persona A but a did value belonging to persona B.
- vaultTaskSigner (packages/core/src/vault/task-signer.ts, opts.entryId, opts.did) constructs a TaskSigner with did: opts.did without validating opts.entryId's principalDid against opts.did locally.
- sign() calls vaultSignTrustTask(opts.session, { entryId, unsignedEnvelope }) which dispatches a Trust Task to the VTA relying entirely on server-side enforcement of 'envelope_issuer_mismatch'.
- If the VTA-side check is absent, misconfigured, or has a logic flaw (not visible in this diff, referenced only in comments), the VTA signs the envelope with entry A's key while returning a proof that the local code trusts.
- The returned proof is mutated into envelope.proof (task-signer.ts,
(envelope as { proof?: unknown }).proof = proof;) without validating the proof's underlying DID against opts.did. - The resulting envelope, signed by entry A's key but carrying issuer=opts.did (persona B), is sent to the RP, which cannot detect the mismatch since it exclusively verifies the Data Integrity proof against the issuer DID document.
- Attacker successfully authenticates as persona B at the RP despite having only rights to persona A's vault entry.
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005
- Data Flows: vaultTaskSigner->VTA vault/sign-trust-task/0.2
Preconditions: Attacker can control or influence the entryId/did pairing passed into vaultTaskSigner (e.g., via a compromised or overly-trusting caller in the extension), The VTA-side 'envelope_issuer_mismatch' check is not authoritative, is bypassable, or has an edge-case flaw, No client-side assertion exists that entryId's principalDid equals opts.did before making the vault call
Existing Controls: VTA is documented to refuse with envelope_issuer_mismatch when envelope.issuer does not match the entry's principalDid (server-side, unverified in this codebase slice) • signOutboundTask performs a local issuer==signer.did check before invoking signer.sign, catching some but not all mismatch classes
Recommended Mitigations: Add a local assertion in vaultTaskSigner or its caller that fetches/validates the vault entry's principalDid and confirms it equals opts.did before dispatching the sign request • Cross-validate the returned proof's verificationMethod DID against opts.did on the client before mutating envelope.proof • Add integration tests asserting VTA-side envelope_issuer_mismatch enforcement, and pin behavior with a security regression test • Require entryId and did to be fetched together from a single trusted source (never independently supplied by disparate call sites)
🟠 STRIDE-2: Removal of Client-Side Holder-Equals-Signer Guard in loginViaTrustTask
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.4 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-863,CWE-284 |
| CAPEC | CAPEC-151,CAPEC-115 |
| OWASP | A01:2021 - Broken Access Control |
Description: loginViaTrustTask in packages/core/src/rp-login/trust-task.ts allows a caller to request an authentication challenge and session for an arbitrary subject DID due to the removal of the local opts.signing.did !== holder.did guard without an equivalent replacement check at this call site, resulting in reliance entirely on remote/downstream enforcement for identity-consistency and loss of defense-in-depth.
Evidence: packages/core/src/rp-login/trust-task.ts:77-110
const subject = opts.subject ?? holder.did;
// The guard that used to live here — "the signing identity must be the
// holder" — has moved to where it can actually be checked.
const challenge = await requestAuthChallenge(sender, { holder, service, issuer: subject, subject, purpose: "login" });
Attack Scenario:
- A caller (compromised extension code, malicious library dependency, or logic bug in a consuming component) invokes loginViaTrustTask with opts.subject set to a DID the caller does not control the signing key for.
- The removed guard (previously
if (opts.signing.did !== holder.did) throw) no longer exists in trust-task.ts; only a comment explains the check 'moved' to signOutboundTask. - requestAuthChallenge is called with issuer: subject (rp-login/trust-task.ts), successfully obtaining a challenge bound to the attacker-chosen subject from the RP, since the RP only refuses at authentication time, not challenge time.
- authenticateSession is invoked with issuer: subject; if the channel's underlying signer (sender) is misconfigured, buggy, or itself compromised (e.g., a rogue TaskSigner implementation slipped in via dependency confusion or supply chain), it may sign with a mismatched key while unintentionally satisfying the local issuer==signer.did check with a spoofed signer.did value.
- Because the safety invariant now spans two separate modules (trust-task.ts and signOutboundTask) with no single authoritative assertion, a bug in the channel's signer implementation (e.g. a custom TaskSigner that lies about its own
didfield) allows challenge and authentication requests for an arbitrary subject to proceed further than the previous single hard-fail guard allowed. - Result: increased surface for identity confusion; a single defense layer replaces what was previously two independent layers (local check in trust-task.ts + local check in signOutboundTask).
🔎 Threat Clue: Derived from COMP-001, COMP-004 via EP-001, EP-006
- Data Flows: loginViaTrustTask->requestAuthChallenge->authenticateSession
Preconditions: A custom or compromised TaskSigner/ChannelSigner implementation that reports an incorrect did, No additional validation between the caller-supplied subject and the vault entry actually used for signing
Existing Controls: signOutboundTask performs envelope.issuer !== signer.did check before signing (packages/core/src/vta/trust-task.ts) • RP performs authoritative server-side check (session.did != input.signer_did in handle_authenticate, per comments)
Recommended Mitigations: Reinstate a defense-in-depth local assertion in loginViaTrustTask comparing the sender's resolved/expected signer DID against opts.subject prior to network calls, where feasible • Add unit/integration tests specifically covering the removed guard's prior failure case to ensure regression cannot silently occur • Document and enforce a strict contract that any TaskSigner implementation's did MUST be cryptographically derived/verifiable, not a freely settable string field • Add telemetry/alerting for challenge requests where subject != holder.did to detect anomalous persona usage patterns
🟡 STRIDE-3: Type-Erasure Bypass via as unknown as Casts in signOutboundTask and vaultTaskSigner
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| 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-704,CWE-20 |
| CAPEC | CAPEC-153 |
| OWASP | A03:2021 - Injection, A08:2021 - Software and Data Integrity Failures |
Description: signOutboundTask and vaultTaskSigner in packages/core/src/vta/trust-task.ts and packages/core/src/vault/task-signer.ts allow malformed or malicious envelope shapes to bypass TypeScript's compile-time type checking due to repeated as unknown as Record<string, unknown> and (envelope as { proof?: unknown }) casts, resulting in potential runtime type confusion where a crafted envelope with unexpected proof or issuer field types is accepted and forwarded.
Evidence: packages/core/src/vault/task-signer.ts:55-72
const proof = (signedEnvelope as { proof?: unknown }).proof;
if (!proof) { throw new Error(...); }
(envelope as { proof?: unknown }).proof = proof;
Attack Scenario:
- A caller constructs or receives (e.g. via a compromised upstream dependency or a deserialization step) a TrustTask envelope object with a
prooffield that is not the expected proof-object shape, or anissuerfield set to an object rather than a string. - vaultTaskSigner casts
envelope as unknown as Record<string, unknown>(task-signer.ts) losing all type safety, then passes it to vaultSignTrustTask. - Upon response, the code checks only
if (!proof)(task-signer.ts) — a falsy check — rather than validating the proof's structural shape (e.g. type, verificationMethod, proofValue fields), so a malformed but truthy proof value (e.g. an empty object{}) passes the check. - The malformed proof is written back with
(envelope as { proof?: unknown }).proof = proof;, silently mutating shared envelope state with unchecked data. - Downstream consumers (RP, VTA reply parsers) that expect a well-formed Data Integrity proof object may throw unhandled exceptions, mishandle the malformed structure, or — in a worse case — treat a permissive/empty proof as valid if their own validation is similarly loose, enabling a request to be treated as signed when it is not.
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005
- Data Flows: vaultTaskSigner->VTA->envelope.proof mutation
Preconditions: A caller or dependency can influence the shape of the proof object returned from vaultSignTrustTask, Downstream proof verification is not strictly schema-validated
Existing Controls: Truthiness check on proof before continuing (task-signer.ts) • signOutboundTask's issuer/signer.did string equality check
Recommended Mitigations: Replace as unknown as double-casts with proper runtime schema validation (e.g. zod/io-ts) for both outbound envelopes and inbound proof objects • Validate proof object shape (required DI proof fields: type, created, verificationMethod, proofValue) before accepting it as non-empty • Add strict TypeScript noImplicitAny/strict in these modules combined with branded types to prevent unsafe casts from being introduced in future changes
🟠 STRIDE-4: Pluggable SiopIdTokenMinter Allows Arbitrary id_token Minting Source Substitution
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-346,CWE-829 |
| CAPEC | CAPEC-151,CAPEC-593 |
| OWASP | A08:2021 - Software and Data Integrity Failures, A07:2021 - Identification and Authentication Failures |
Description: loginViaSiop in packages/core/src/siop/login-client.ts allows substitution of the id_token issuance mechanism via the pluggable SiopIdTokenMinter interface due to lack of validation that opts.minter.did corresponds to a minter actually authorized/entitled to sign for that DID, resulting in potential minting of id_tokens for DIDs the caller does not legitimately control.
Evidence: packages/core/src/siop/login-client.ts:84-119
const idToken = await opts.minter.mint({ audience: opts.rpDid, nonce: challenge.challenge });
const envelope = { id: `urn:uuid:${globalThis.crypto.randomUUID()}`, type: TASK_AUTH_AUTHENTICATE, issuer: opts.minter.did, ... };
Attack Scenario:
- A caller supplies a custom SiopIdTokenMinter implementation to loginViaSiop where
didis set to a victim persona's DID butmint()is implemented to call an attacker-controlled signing backend or reuse a cached/stolen token. - loginViaSiop requests a challenge using
opts.minter.did(login-client.ts,body: JSON.stringify({ did: opts.minter.did })) without validating that this minter is bound/scoped to only mint for DIDs it is entitled to. idToken = await opts.minter.mint({ audience: opts.rpDid, nonce: challenge.challenge })is invoked; the function trusts the minter's return value completely — there is no verification in loginViaSiop that the returned JWT'siss/subactually equalsopts.minter.did.- The resulting envelope sets
issuer: opts.minter.did(login-client.ts) directly from the minter's claimed did, not from parsing/validating the token's actual claims. - If the RP's validation of
id_tokensignature-vs-claimed-issuer has any leniency or if the minter is compromised (e.g., malicious npm dependency implementing SiopIdTokenMinter), an attacker can present forged authentication for the victim persona's DID. - Because this is the first version where minting is externally pluggable (previously hardcoded to selfIssuedMinter/local signing), this widens the trusted computing base to any code implementing the interface.
🔎 Threat Clue: Derived from COMP-002 via EP-002, EP-003
- Data Flows: loginViaSiop->SiopIdTokenMinter.mint->RP /auth/challenge
Preconditions: Attacker can supply, inject, or compromise a SiopIdTokenMinter implementation used by the wallet (e.g. via supply-chain attack, malicious extension update, or a permissive plugin architecture), No independent verification of the minted token's actual iss claim against opts.minter.did prior to trusting it as the envelope issuer
Existing Controls: RP performs authoritative check that the DID that signed matches the DID the challenge was issued to (session.did != input.signer_did), described in comments but not verifiable in this code slice • selfIssuedMinter's did is directly derived from signing.did, reducing risk for the default/built-in minter
Recommended Mitigations: Decode and verify the minted id_token's iss and sub claims client-side match opts.minter.did before constructing the envelope and sending it, failing fast on mismatch • Restrict which SiopIdTokenMinter implementations can be registered/loaded (e.g. an allowlist, or requiring cryptographic attestation of the minter's origin) • Add supply-chain controls (dependency pinning, SRI/checksums, code signing) for any package providing custom minter implementations • Add explicit unit tests asserting loginViaSiop rejects a minter whose minted token's issuer does not match minter.did
🟡 STRIDE-5: Optional issuer Parameter Default Fallback Creates Silent Persona Confusion in auth-tasks.ts
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation |
| 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-671,CWE-697 |
| CAPEC | CAPEC-115 |
| OWASP | A04:2021 - Insecure Design |
Description: requestAuthChallenge, authenticateSession, refreshAuthSession, startPasskeyLogin, and finishPasskeyLogin in packages/core/src/vta/auth-tasks.ts allow silent issuer substitution due to the params.issuer ?? params.holder.did fallback pattern applied uniformly across five distinct auth flows, resulting in inconsistent or unexpected issuer values if a caller forgets to pass a matching issuer across a multi-step flow (e.g. challenge with issuer=personaA, authenticate with issuer=holder default).
Evidence: packages/core/src/vta/auth-tasks.ts:81,126,160,218,255
issuer: params.issuer ?? params.holder.did,
Attack Scenario:
- A calling component invokes requestAuthChallenge with
issuer: subject(a persona DID) to get a challenge, as in loginViaTrustTask. - Due to a coding error, race condition, or inconsistent state management elsewhere in a consuming application, a subsequent call to authenticateSession omits
issuer, causingparams.issuer ?? params.holder.didto silently default toholder.didinstead of the persona subject used for the challenge. - The envelope sent to authenticateSession now has
issuer = holder.didwhile the challenge was issued and bound to the persona DID. - signOutboundTask's issuer==signer.did check may still pass locally if the underlying channel's signer.did happens to equal holder.did (i.e., using the default holder signer), masking the fact that the wrong identity is authenticating relative to the original challenge intent.
- The RP either refuses (if it strictly checks the challenge's bound subject against the authenticate issuer) — causing a confusing failure with no direct indication that a caller-side default fallback was the root cause — or, in a lenient RP implementation, incorrectly authenticates the holder identity for a session initiated as a persona, causing session/identity confusion in audit logs and downstream authorization decisions.
- Because five separate call sites duplicate this exact
??fallback pattern instead of sharing one enforced invariant, a fix or validation added to one is not guaranteed to be applied to the others, increasing risk of divergent behavior.
🔎 Threat Clue: Derived from COMP-004 via EP-006
- Data Flows: requestAuthChallenge->authenticateSession->refreshAuthSession issuer propagation
Preconditions: A caller uses different auth-tasks.ts functions across a multi-step flow without consistently threading the same issuer argument, No shared validation ensures issuer consistency across the challenge -> authenticate -> refresh lifecycle
Existing Controls: RP's session.did != input.signer_did check (server-side, described in comments) provides some authoritative backstop • signOutboundTask's local issuer==signer.did equality check catches issuer/signer mismatches, though not issuer/challenge-subject mismatches
Recommended Mitigations: Introduce a session/context object that threads a single resolved issuer value through the entire login flow rather than re-deriving/defaulting it independently at each call site • Add explicit runtime assertions comparing the challenge's bound subject to the authenticate/refresh issuer before dispatch • Add comprehensive integration tests covering multi-step flows with a persona subject to catch silent holder-default fallback regressions • Emit warning-level logs when params.issuer is omitted for a call following a prior call in the same logical session that specified a persona issuer
🟡 STRIDE-6: ChannelSigner Type Widening in DidcommVtaTransport and RestChannel Removes Compile-Time Key-Identity Binding
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| 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-345,CWE-284 |
| CAPEC | CAPEC-151 |
| OWASP | A08:2021 - Software and Data Integrity Failures |
Description: DidcommVtaTransport and RestChannel in packages/core/src/vta/didcomm.ts and packages/core/src/vta/rest-channel.ts allow arbitrary ChannelSigner implementations to be substituted for the previously concrete SigningIdentity type due to the asTaskSigner(opts.signing) conversion accepting the new broader ChannelSigner type, resulting in reduced compile-time guarantees that the signer used for every outbound Trust Task actually corresponds to a locally-held, verifiable key.
Evidence: packages/core/src/vta/didcomm.ts:48,75-79,238
signing: ChannelSigner;
...
this.signer = asTaskSigner(opts.signing);
...
await signOutboundTask(envelope, this.signer);
Attack Scenario:
- Prior to this change,
signing: SigningIdentityin DidcommVtaTransportOptions and RestChannelOptions enforced (at least at the type level) that a concrete, locally-verifiable key was supplied to the transport constructor. - This diff changes the field type to
ChannelSigner(didcomm.ts, rest-channel.ts) and immediately wraps it viaasTaskSigner(opts.signing), broadening what implementations are accepted at both compile time and runtime. - A malicious or buggy dependency, or a misconfigured DI/plugin registration, supplies a
ChannelSignerobject whosesign()implementation does not actually perform cryptographic signing correctly (e.g., a no-op stub used accidentally in production due to environment misconfiguration) or that always resolves without producing a genuine proof, and whosedidfield is attacker/developer-controlled. - Every outbound message from
send/notifyin DidcommVtaTransport (await signOutboundTask(envelope, this.signer)) and RestChannel is silently signed (or 'signed') by this broadened signer without any additional runtime assertion that the signer is a genuine key-holder for its claimed did beyond the string-equality check in signOutboundTask. - Because the check in signOutboundTask only verifies
envelope.issuer === signer.did(both attacker/developer-controlled string fields) rather than verifying possession of key material, a misconfigured or malicious ChannelSigner can pass this check trivially while producing invalid, forged, or absent proofs, which is only caught downstream by the RP's cryptographic proof verification (outside this codebase). - This shifts a class of configuration/dependency errors from a compile-time-shape mismatch (previously requiring a true SigningIdentity) to a runtime string-equality check with no cryptographic backing at the client.
🔎 Threat Clue: Derived from COMP-006, COMP-007 via EP-007, EP-008
- Data Flows: DidcommVtaTransport.send/notify->signOutboundTask, RestChannel.send->signOutboundTask
Preconditions: A ChannelSigner implementation that is not a genuine key-holder is registered/wired into DidcommVtaTransport or RestChannel (via misconfiguration, dependency injection error, or compromised plugin), Reliance solely on the RP's downstream proof verification to catch the resulting invalid signatures
Existing Controls: signOutboundTask's issuer/signer.did equality check provides a naming consistency guard • RP-side Data Integrity proof cryptographic verification is the ultimate backstop (external to this codebase)
Recommended Mitigations: Add a runtime self-test/handshake for any registered ChannelSigner (e.g. sign-and-verify a known test payload against the signer's claimed DID document) before it is used operationally • Restrict which modules/environments are permitted to construct DidcommVtaTransport/RestChannel with a non-default ChannelSigner via configuration allowlisting • Add typed marker/brand types to distinguish 'verified local key' signers from 'remote/pluggable' signers, and require explicit opt-in for the latter • Add structured logging/telemetry for every signOutboundTask invocation recording signer type/origin for post-hoc audit
🟡 STRIDE-7: Missing Rate Limiting on Unauthenticated /auth/challenge Endpoint Enables DID Enumeration and Flooding
| Field | Detail |
|---|---|
| Category | Information Disclosure, Denial of Service |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.5 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-112,CAPEC-125 |
| OWASP | A04:2021 - Insecure Design, A07:2021 - Identification and Authentication Failures |
Description: POST ${baseUrl}/auth/challenge in packages/core/src/siop/login-client.ts allows unauthenticated, unlimited challenge requests for arbitrary DID values due to the client sending { did: opts.minter.did } with no client-side or evidenced server-side throttling, resulting in potential DID enumeration, resource exhaustion, and nonce-harvesting for downstream replay or brute-force attempts against the RP.
Evidence: packages/core/src/siop/login-client.ts:84-90
const challengeRes = await fetchFn(`${base}/auth/challenge`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ did: opts.minter.did }),
});
Attack Scenario:
- An attacker directly calls the RP's
/auth/challengeendpoint (identified as EP-003, unauthenticated per recon) with a rapid sequence of{ did: <guessed-or-enumerated-DID> }bodies, since login-client.ts shows this is the exact unauthenticated request shape the legitimate client sends. - Because the endpoint is unauthenticated (per recon
auth_required: false) and the client code shows no rate-limiting, backoff, or CAPTCHA logic, an attacker can send a very high volume of requests. - Differential response behavior (e.g., timing differences, distinct error codes for 'DID not found' vs 'DID found, challenge issued') could allow the attacker to enumerate which DIDs are registered/known to the RP.
- Repeated challenge issuance for a victim's DID also lets an attacker harvest many valid nonces tied to that DID, increasing the attack surface for any weakness in nonce lifecycle/expiry handling at the RP.
- At sufficient volume, this also serves as a resource-exhaustion vector against the RP's challenge-issuance subsystem (each request presumably requires state creation/storage for
sessionId/challenge), degrading availability for legitimate users.
🔎 Threat Clue: Derived from COMP-002 via EP-003
- Data Flows: Client->RP /auth/challenge
Preconditions: RP does not implement server-side rate limiting, CAPTCHA, or anomaly detection on /auth/challenge (unverifiable from this code slice, but no client-side mitigation exists and the endpoint is unauthenticated by design), Attacker has network access to the RP's auth API
Existing Controls: None visible in the provided client code; any mitigation would need to exist server-side at the RP, which is out of scope for this repository
Recommended Mitigations: Recommend server-side rate limiting, per-IP and per-DID throttling, and CAPTCHA/proof-of-work challenges on /auth/challenge (RP-side, outside this repo but should be flagged to RP maintainers) • Ensure uniform response timing/shape regardless of DID existence to prevent enumeration • Set short expiry and single-use enforcement on issued challenges to limit the value of harvested nonces • Add client-side backoff/jitter for repeated challenge requests to reduce accidental self-inflicted load, though this does not stop a malicious direct caller
🔵 STRIDE-8: Non-Constant-Time / Non-Cryptographic did String Comparisons Enable Confusable DID Spoofing
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering |
| Severity | Low |
| Likelihood | Unlikely |
| CVSS | 4.5 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-178,CWE-697 |
| CAPEC | CAPEC-627 |
| OWASP | A04:2021 - Insecure Design |
Description: signOutboundTask in packages/core/src/vta/trust-task.ts allows potential confusable-DID bypass due to relying purely on JavaScript string equality (!==) for issuer/signer DID comparison without unicode normalization or canonical DID-syntax validation, resulting in a theoretical risk that visually or byte-level distinct but semantically ambiguous DID strings (e.g., differing only by trailing whitespace, homoglyphs, or unnormalized percent-encoding) could confuse this check or downstream consumers.
Evidence: packages/core/src/vta/trust-task.ts:105-121
if (envelope.issuer !== undefined && envelope.issuer !== signer.did) {
throw new VtaClientError("e.client.identity", ...);
}
await signer.sign(envelope);
Attack Scenario:
- An attacker or a buggy upstream component supplies an
envelope.issuerstring that is byte-different fromsigner.didbut resolves (per a lenient or non-canonicalizing DID resolver elsewhere in the stack) to the same underlying DID document — e.g. via trailing whitespace, differing percent-encoding of special characters, or case differences in method-specific-id where the method permits case-insensitivity. signOutboundTask's checkenvelope.issuer !== signer.did(packages/core/src/vta/trust-task.ts) treats these as different values and throws — this is the safe default — but the inverse risk exists: a component upstream of this check (not shown, potentially in buildTrustTask or a caller) might constructenvelope.issuerfrom a differently-encoded but resolver-equivalent string tosigner.did, causing the strict check to incorrectly ALLOW two strings that a human or a downstream RP might treat as different identities, or REJECT two strings a downstream system treats as the same, producing inconsistent authorization decisions across the trust boundary.- This class of bug is most exploitable if any DID-consuming component elsewhere applies normalization while this check does not, creating a confused-deputy style mismatch between what the wallet 'signs as' and what the RP 'authenticates as'.
🔎 Threat Clue: Derived from COMP-004, COMP-006, COMP-007 via EP-006, EP-007, EP-008
- Data Flows: signOutboundTask issuer/signer.did comparison
Preconditions: A DID resolver or downstream consumer applies different normalization rules than the raw string comparison used here, Attacker can supply or influence a byte-distinct but resolver-equivalent DID string into either envelope.issuer or a signer's did field
Existing Controls: Strict !== string comparison is a fail-closed default for exact mismatches • DID method specifications generally define canonical forms, reducing but not eliminating ambiguity risk
Recommended Mitigations: Apply DID canonicalization/normalization consistently before any equality comparison in signOutboundTask and all issuer-setting call sites • Add unit tests with edge-case DID strings (differing case, trailing whitespace, encoded characters) to confirm consistent accept/reject behavior matching the RP's own normalization • Document the exact expected DID string canonical form as a type-level invariant (e.g., branded CanonicalDid type) enforced at construction time
🟡 STRIDE-9: Insufficient Repudiation Controls for Persona-Signed Trust Tasks
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.6 CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-778,CWE-223 |
| CAPEC | CAPEC-593 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: vaultTaskSigner and signOutboundTask across packages/core/src/vault/task-signer.ts and packages/core/src/vta/trust-task.ts allow an authenticated holder to later deny having authorized a persona-signed action due to the absence of any visible local audit log correlating which holder session requested which VTA-signed persona envelope, resulting in weakened non-repudiation for actions taken under a persona identity distinct from the holder.
Evidence: packages/core/src/vault/task-signer.ts:48-77
sign: async (envelope) => {
const { signedEnvelope } = await vaultSignTrustTask(opts.session, { holder: opts.holder, service: opts.service, entryId: opts.entryId, unsignedEnvelope: envelope });
...
}
Attack Scenario:
- A holder's session initiates
vaultTaskSigner.sign(envelope)for a persona DID via the VTA (task-signer.ts), producing a signed Trust Task sent to an RP under the persona's identity. - No code in the reviewed files persists a local, tamper-evident record (e.g., an append-only log entry binding holder session ID, timestamp, entryId, target RP, and resulting envelope ID) at the point the signing request is made.
- If the persona later performs an action the holder disputes having authorized (e.g., in a shared-device or multi-user browser profile scenario), there is no client-side evidence trail to corroborate or refute the holder's claim, shifting the entire evidentiary burden to the VTA's own (unverified in this codebase) server-side logs.
- This is compounded by the fact that persona signing keys 'live at the VTA and never leave it' (per code comments), meaning the wallet/browser extension is the only party positioned to record contextual intent (why this action, which UI flow triggered it) that the VTA alone cannot reconstruct.
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005
- Data Flows: Holder session->vaultTaskSigner->VTA sign-trust-task
Preconditions: Dispute arises over whether a persona-signed action was authorized by the legitimate holder, No local audit trail exists to corroborate intent at time of signing
Existing Controls: VTA presumably logs sign-trust-task requests server-side (unverified, out of scope for this repo) • holder session authentication to the VTA (vault/sign-trust-task/0.2) provides some level of accountability at the transport layer
Recommended Mitigations: Add local, tamper-evident audit logging in vaultTaskSigner capturing holder DID, entryId, target service, envelope type/id, and timestamp prior to dispatch • Surface a user-visible confirmation/consent UI step (referencing packages/extension/src/confirm.tsx) specifically for persona-signing actions, with the confirmation decision logged • Coordinate with VTA maintainers to ensure sign-trust-task requests are logged with sufficient correlation IDs to reconcile with client-side records
🟠 STRIDE-10: Extension Message Bridge Lacks Sender Authentication for Trust Task Dispatch
| Field | Detail |
|---|---|
| Category | Spoofing, Elevation of Privilege |
| Severity | High |
| Likelihood | Possible |
| CVSS | 7.6 CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-346,CWE-862 |
| CAPEC | CAPEC-664,CAPEC-115 |
| OWASP | A01:2021 - Broken Access Control |
Description: background.ts message bridge (chrome.runtime messaging, EP-009) allows any extension context or content script to trigger authenticated login/signing flows due to unauthenticated inbound message handling (auth_required: false per recon) at the extension messaging boundary, resulting in potential unauthorized invocation of loginViaTrustTask, loginViaSiop, or vaultTaskSigner-backed signing flows by malicious or compromised page content.
Evidence: packages/extension/src/background.ts:N/A
N/A — background.ts not included in reduced source slice; entry point derived from recon EP-009 (chrome.runtime messaging, inferred, auth_required: false)
Attack Scenario:
- A malicious or compromised website injects content that sends a
chrome.runtime.sendMessage-style message (or window.postMessage relayed by bridge-protocol.ts) attempting to reach background.ts's message handler. - Because EP-009 is marked unauthenticated in recon and no sender-origin/sender-id validation is evidenced in the provided files, background.ts may process the message as if it originated from the legitimate confirm.tsx / offscreen.ts UI flow.
- If the message schema allows triggering
loginViaTrustTask/loginViaSiop/persona-signing flows with attacker-influenced parameters (e.g., target RP service DID, subject persona), the extension could be coerced into signing or authenticating against an RP chosen by the attacker. - Combined with STRIDE-1/STRIDE-2 (persona/subject confusion), a successful message-spoofing attack could chain into unauthorized cross-persona authentication without the user's
confirm.tsxconsent screen being genuinely shown, if the confirmation step can be bypassed or auto-approved under attacker-controlled conditions. - Result: a website-level attacker escalates to invoking wallet-privileged operations that should require explicit, authenticated user/extension-UI consent.
🔎 Threat Clue: Derived from COMP-008 via EP-009
- Data Flows: Web page/content script->background.ts message bridge->loginViaTrustTask/loginViaSiop/vaultTaskSigner
Preconditions: background.ts message handler does not verify sender.id/sender.origin matches expected extension/page context, Message schema permits invoking sensitive login/signing operations directly from inbound messages, No mandatory user-interactive confirmation gate before wallet-privileged actions execute
Existing Controls: Chrome extension messaging APIs provide sender.tab/sender.origin metadata that COULD be checked (not confirmed present in this file subset) • confirm.tsx suggests a user-consent UI step exists somewhere in the flow, which may mitigate silent exploitation if enforced consistently
Recommended Mitigations: Enforce strict sender.id/sender.origin allowlisting in background.ts for every message type that can trigger login or signing flows • Require explicit, non-bypassable user confirmation (confirm.tsx) before any persona-signing or login operation proceeds, with no default-approve code path • Apply the principle of least privilege to the message schema: separate low-risk status queries from high-risk signing/login triggers, with different authorization requirements • Add integration tests simulating malicious postMessage/sendMessage payloads to confirm they are rejected
🍝 PASTA Threat Model
Application Purpose
A browser-extension-based digital wallet implementing DIDComm and SIOP-based decentralized identity authentication, enabling users to log into relying parties either as their holder identity or as per-site personas whose signing keys are managed remotely by a Vault/Trust-Task-Agent (VTA).
Inherent Risks
- The wallet holds and delegates cryptographic signing authority for both a primary holder identity and multiple remotely-keyed per-site personas.
- Authentication correctness depends on distributed invariant enforcement across client, VTA, and RP components that are not all visible/auditable within this repository.
- Browser extension architecture inherently exposes a message-passing attack surface to untrusted web content.
Objectives
Risk: Do not allow a single missing local assertion to be the sole barrier between a benign refactor and full persona impersonation.; Treat any newly pluggable interface (TaskSigner, ChannelSigner, SiopIdTokenMinter) as an expansion of the trusted computing base requiring compensating controls.
Business: Enable privacy-preserving per-site persona logins without requiring the holder's primary key to be exposed to every relying party.; Support pluggable identity/signing backends (self-issued vs. VTA-hosted) to broaden interoperability with different relying party ecosystems.
Security: Ensure that a signed document's issuer DID always matches the DID of the party whose private key actually produced the signature.; Prevent any caller — malicious or buggy — from causing the wallet to sign or mint credentials for an identity the current session is not entitled to use.; Ensure the browser extension's message-passing surface only accepts privileged instructions from trusted, authenticated senders.
Financial: Avoid liability from account takeover or persona impersonation incidents that could trigger regulatory fines or user compensation claims.
Compliance: Maintain non-repudiation properties adequate for identity-assurance frameworks (e.g. eIDAS-like assurance levels) governing decentralized identity wallets.; Preserve auditability of signing operations sufficient for dispute resolution and regulatory inquiry.
Functional: Support both self-issued (selfIssuedMinter) and VTA-minted (vaultTaskSigner) identity token issuance paths.; Support login via Trust Task protocol and SIOP protocol against relying parties with differing capability support.
Operational: Maintain reliable challenge-response authentication flows across DIDComm and REST transport channels.; Ensure consistent behavior of the auth-tasks.ts issuer-default pattern across all five auth flow functions.
Business Impact Analysis (3)
BIA-1: Persona-Based Relying Party Authentication (Critical)
End-to-end flow where a user authenticates to a relying party either as their holder identity or as a VTA-hosted persona, producing a session the RP trusts for subsequent authorization decisions.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: 00 days 00:15 hours
- Stakeholders: Extension End Users / Relying Parties (RPs) / VTA Operators / Wallet/Extension Maintainers
- Dependencies: DIDComm Transport (DidcommVtaTransport) / REST Channel (RestChannel) / Trust Task Protocol Envelope/Proof System / VTA Vault Service (sign-trust-task endpoint) / Relying Party Auth API (/auth/challenge, handle_authenticate)
- Disruptions: Persona impersonation allowing cross-account access at an RP / Removal or regression of the holder/signer identity-consistency guard / VTA outage preventing persona-signed authentication entirely / Malicious extension message triggering unauthorized login/signing
- Impacts: Unauthorized account access at one or more relying parties, potentially exposing PII or enabling fraudulent transactions / Loss of user trust in the wallet's core security guarantee (that a persona cannot sign as another persona) / Regulatory scrutiny/incident reporting obligations if unauthorized authentication is confirmed in production / Support/incident-response cost to investigate and remediate identity-confusion incidents
BIA-2: Vault-Backed Persona Document Signing (High)
Flow where the wallet requests the VTA to sign an outbound Trust Task envelope using a persona's vault-held key, returning a proof that is attached to the envelope before it is sent to a relying party.
MTD: 02 days 00:00 hours | RTO: 00 days 08:00 hours | RPO: 00 days 01:00 hours
- Stakeholders: Extension End Users / VTA Operators / Relying Parties
- Dependencies: Vault Entry Storage (VTA-side) / vault/sign-trust-task/0.2 protocol / TaskSigner abstraction
- Disruptions: entryId/did mismatch allowing wrong-key signing / VTA returning malformed or empty proof objects / VTA service unavailability blocking all persona-based signing
- Impacts: Cross-persona signature forgery enabling impersonation at an RP / Failed logins for all persona users during VTA outage, degrading service availability / Increased support burden triaging 'proof missing' errors
BIA-3: Extension Message-Bridge Mediated User Actions (High)
Flow where UI or page-context messages are relayed through background.ts to trigger wallet operations such as login initiation, confirmation display, and signing dispatch.
MTD: 01 days 00:00 hours | RTO: 00 days 04:00 hours | RPO: N/A
- Stakeholders: Extension End Users / Website Operators (benign and malicious) / Wallet Maintainers
- Dependencies: chrome.runtime messaging APIs / bridge-protocol.ts message schema / confirm.tsx consent UI / offscreen.ts execution context
- Disruptions: Unauthenticated or unauthorized message triggering privileged wallet operations / Consent UI bypass allowing silent authorization
- Impacts: Unauthorized login/signing operations triggered by malicious web content / Erosion of user trust in extension security boundary
Technical Scope
Roles (4): RO-1 Wallet Holder · RO-2 Per-Site Persona · RO-3 VTA Operator · RO-4 Relying Party
Actors (4): AC-1 Extension End User · AC-2 Wallet Core Library · AC-3 VTA Service Process · AC-4 Relying Party Auth Service
Entry Points (9): EP-001 loginViaTrustTask · EP-002 loginViaSiop · EP-003 RP Auth Challenge Endpoint · EP-004 vaultTaskSigner.sign · EP-005 VTA sign-trust-task Endpoint · EP-006 Auth Task Envelope Builders · EP-007 REST Trust Task Dispatch · EP-008 DIDComm Message Send/Notify · EP-009 Extension Message Bridge
Threat Actors (4): TA-1 Malicious Website Operator · TA-2 Compromised Dependency / Supply-Chain Actor · TA-3 Malicious or Compromised VTA-Side Insider · TA-4 Network-Positioned Attacker
Infrastructure (3): IF-1 Browser Extension Runtime · IF-2 VTA Hosted Service · IF-3 Relying Party Hosted Service
Trust Boundaries (4): TB-1 Browser Extension Boundary · TB-2 Wallet-to-VTA Trust Boundary · TB-3 Wallet-to-RP Trust Boundary · TB-4 Internal Core Library Boundary
External Entities (3): EE-1 Relying Party (RP) · EE-2 VTA Vault Service · EE-3 Untrusted Web Page / Content Script
System Components (10): SC-1 loginViaTrustTask / rp-login module · SC-2 loginViaSiop / SIOP login client · SC-3 vaultTaskSigner / Vault Module · SC-4 auth-tasks.ts Auth Task Callers · SC-5 trust-task.ts signOutboundTask / TaskSigner Core · SC-6 DidcommVtaTransport · SC-7 RestChannel · SC-8 Extension Background Message Bridge · SC-9 VTA Vault Service · SC-10 Relying Party Auth API
Resources And Assets (5): RA-1 Holder Signing Identity · RA-2 Persona Signing Key (VTA Vault Entry) · RA-3 Trust Task Envelope / Proof · RA-4 Authentication Challenge/Nonce/SessionId · RA-5 SIOP id_token
Technologies And Dependencies (1):
Use Cases (3)
- Holder Self-Issued RP Login via Trust Task: The wallet holder authenticates directly to a relying party using their own browser-held signing key, obtaining an authenticated RP session without involving any persona or the VTA.
- Per-Site Persona Login via VTA-Backed Signing: The wallet holder authenticates to a relying party as a per-site persona, whose signing key is held remotely at the VTA vault service and never leaves it, with the wallet requesting the VTA to sign th
- SIOP Login with Pluggable id_token Minter: The wallet authenticates to a relying party using the SIOP protocol, obtaining a challenge nonce and minting a self-issued or VTA-minted id_token that is delivered to the RP as proof of identity.
📋 Risk Registry (6)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Cross-persona impersonation via unverified vault entryId-to-DID binding | Critical | High | Immediate | Medium |
| RISK-002 | Loss of defense-in-depth from removed holder-equals-signer client guard | High | Medium | Short-Term | Low |
| RISK-003 | Untrusted pluggable id_token minting source widening trusted computing base | High | Medium | Short-Term | Medium |
| RISK-004 | Unauthenticated challenge endpoint enabling enumeration and flooding | Medium | Medium | Medium-Term | Low |
| RISK-005 | Unauthenticated extension message bridge exposing privileged operations to web content | High | Medium | Immediate | Medium |
| RISK-006 | Silent issuer default fallback causing persona/holder confusion across multi-step auth flows | Medium | Low | Medium-Term | Medium |
⚔️ Attack Scenarios (4)
SC-3: vaultTaskSigner / Vault Module
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC3@{ shape: rect, label: "SC-3: vaultTaskSigner / Vault Module" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE346@{ shape: rect, label: "CWE-346: Origin Validation Error" }
CWE863@{ shape: rect, label: "CWE-863: Incorrect Authorization" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC151@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
CAPEC196@{ shape: rect, label: "CAPEC-196: Session Credential Falsification" }
end
subgraph SL4["4. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Persona Impersonation via Unverified entryId-to-DID Binding<br><i>Critical / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA3@{ shape: rect, label: "TA-3: Malicious or Compromised VTA-Side Insider<br><i>Abuse loose entryId/did binding</i>" }
end
SC3 --> CWE346
SC3 --> CWE863
CWE346 --> CAPEC151
CWE863 --> CAPEC196
CAPEC151 --> S1
CAPEC196 --> S1
S1 --> TA3
linkStyle 0 stroke:#A50000, stroke-width:2px
linkStyle 1 stroke:#A50000, stroke-width:2px
linkStyle 2 stroke:#A50000, stroke-width:2px
linkStyle 3 stroke:#A50000, stroke-width:2px
linkStyle 4 stroke:#A50000, stroke-width:2px
linkStyle 5 stroke:#A50000, stroke-width:2px
linkStyle 6 stroke:#A50000, stroke-width:2px
SC-1: loginViaTrustTask / rp-login module
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: loginViaTrustTask / rp-login module" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE863B@{ shape: rect, label: "CWE-863: Incorrect Authorization" }
CWE284@{ shape: rect, label: "CWE-284: Improper Access Control" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC151B@{ shape: rect, label: "CAPEC-151: Identity Spoofing" }
CAPEC115@{ shape: rect, label: "CAPEC-115: Authentication Bypass" }
end
subgraph SL4["4. Threats"]
direction LR
S2@{ shape: rect, label: "STRIDE-2: Removal of Client-Side Holder-Equals-Signer Guard<br><i>High / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA2@{ shape: rect, label: "TA-2: Compromised Dependency / Supply-Chain Actor<br><i>Inject malicious signer implementation</i>" }
end
SC1 --> CWE863B
SC1 --> CWE284
CWE863B --> CAPEC151B
CWE284 --> CAPEC115
CAPEC151B --> S2
CAPEC115 --> S2
S2 --> TA2
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FF0000, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FF0000, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FF0000, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
SC-2: loginViaSiop / SIOP login client
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: loginViaSiop / SIOP login client" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
CWE307@{ shape: rect, label: "CWE-307: Improper Restriction of Excessive Authentication Attempts" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC593@{ shape: rect, label: "CAPEC-593: Session Hijacking" }
CAPEC112@{ shape: rect, label: "CAPEC-112: Brute Force" }
end
subgraph SL4["4. Threats"]
direction LR
S4@{ shape: rect, label: "STRIDE-4: Pluggable SiopIdTokenMinter Allows Arbitrary id_token Minting<br><i>High / Possible</i>" }
S7@{ shape: rect, label: "STRIDE-7: Missing Rate Limiting on /auth/challenge<br><i>Medium / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA2B@{ shape: rect, label: "TA-2: Compromised Dependency / Supply-Chain Actor<br><i>Inject malicious minter</i>" }
TA4@{ shape: rect, label: "TA-4: Network-Positioned Attacker<br><i>Enumerate or flood challenge endpoint</i>" }
end
SC2 --> CWE345
SC2 --> CWE307
CWE345 --> CAPEC593
CWE307 --> CAPEC112
CAPEC593 --> S4
CAPEC112 --> S7
S4 --> TA2B
S7 --> TA4
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FFA500, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FFA500, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FFA500, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
linkStyle 7 stroke:#FFA500, stroke-width:2px
SC-8: Extension Background Message Bridge
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC8@{ shape: rect, label: "SC-8: Extension Background Message Bridge" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE346B@{ shape: rect, label: "CWE-346: Origin Validation Error" }
CWE862@{ shape: rect, label: "CWE-862: Missing Authorization" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC664@{ shape: rect, label: "CAPEC-664: Server Side Request Forgery (message analog)" }
CAPEC115B@{ shape: rect, label: "CAPEC-115: Authentication Bypass" }
end
subgraph SL4["4. Threats"]
direction LR
S10@{ shape: rect, label: "STRIDE-10: Extension Message Bridge Lacks Sender Authentication<br><i>High / Possible</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Malicious Website Operator<br><i>Trigger unauthorized wallet operations</i>" }
end
SC8 --> CWE346B
SC8 --> CWE862
CWE346B --> CAPEC664
CWE862 --> CAPEC115B
CAPEC664 --> S10
CAPEC115B --> S10
S10 --> TA1
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FF0000, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FF0000, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FF0000, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
📊 Risk Summary
Total Threats: 10
By Severity: Low: 1 · High: 3 · Medium: 5 · Critical: 1
By Category: Spoofing: 6 · Tampering: 5 · Elevation of Privilege: 4 · Information Disclosure: 2 · Repudiation: 2 · Denial of Service: 1
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 0 | 2 |
Must-Review-By-Human (2)
- 🔵 Unsafe Formatstring (3 occurrences)
- 🟡 SigningIdentity/ChannelSigner abstraction moves trust boundary to remote VTA without validating persona binding before session/document construction
login()— the REST SIOPv2 path, and the first one an RP page reaches for — signed as the wallet's holder DID for every site, unconditionally and with no signal. It read no vault entry and took no parameter that could change that, whilesignin-flow.tsxtold the operator, on screen:That was true of
proxyLoginand false of the button beside it. This closes the gap on both RP-facing login paths. The explainer becomes true.What the operator sees
First sign-in at a site raises the picker; every sign-in after is the ordinary consent prompt.
The holder is an offered answer, not a silent fallback
The RP's ACL is checked against whichever DID signs in, and every enrolment made before personas existed names the holder —
vtc admin invite --did <your-did>is what put it there. Dropping the route would break those sites on the next sign-in, with an RP refusal as the only clue.So it stays, chosen explicitly with the consequence on screen (every site holding that DID can recognise the same person), and remembered per origin in
site-identity.ts. It is offered only where it can be honoured —login()self-issues from a key the browser holds;proxyLoginandwalletProfilemint through a vault entry, and the holder is not one, soallowHoldergates the option rather than presenting a choice that fails after it is made.A persona always beats a recorded holder choice. It is the more specific statement about the site and the one visible and revocable in the vault. Reading the local record first would let a stale choice mask an entry bound later through
proxyLogin, and the sign-in would use an identity the vault contradicts. Binding a persona clears the record, so there is never a second answer sitting behind the first.Two mechanisms, one for each path
REST — the id_token source became a parameter.
SiopIdTokenMinter, withselfIssuedMinter()for the holder and a VTA-backed one in the offscreen for a persona. That keepssiopat its layer (the persona mint needsvault, two layers up) andloginViaSiopstops taking aSigningIdentityit could only use one way.DIDComm/TSP — the channel can sign as a persona.
signOutboundTasktakes aTaskSigner({ did, sign(envelope) }) instead of a private key.localTaskSignerfor the holder's key;vaultTaskSignerasks the VTA viavault/sign-trust-task/0.2. The channel cannot tell them apart, and neither can the RP — it verifies a proof against a DID and never learns where the bytes were produced.The signer stays REQUIRED: widening the type does not weaken the rule CLAUDE.md records, since a channel with no signer would still silently send unsigned documents. Only the key's location changed. Channel options accept
SigningIdentity | TaskSignerand normalise once at construction — the fourteen deprecated*Resthelpers thread an identity through their own public options types, and rewriting all of them would say something none of their callers need to know.Transport sender and document signer are now separate
Because the RP treats them as separate:
handle_authenticateestablishes the caller from the signature (session.did != input.signer_didin vti-common), not from who delivered the frame. A persona login rides the wallet's own transport — there is no second mediator session, and the persona has no local key to open one — while the documents are issued by and signed as the persona.SessionIdentitygainsdocumentSignerrather than wideningsigning, which would have handed a keyless signer totspHolderIdentityFromSecretand failed far from the cause.The guard that moved
loginViaTrustTask'ssigning.did !== holder.didcheck is gone. It could not survive a channel signing as a persona, and it was checking the wrong thing anyway — that function never signs. The rule it stood for is enforced where the signature happens, on every outbound document, bysignOutboundTask's existing issuer check.Its test now pins the behaviour that replaced it: that a named subject issues both auth documents. Requesting the challenge for the persona and issuing the authenticate as the holder would be refused by the RP on the signer check — after the operator had approved the sign-in — so the test asserts both envelopes, not just the first.
holderDidin both results is now the DID that actually signed. Reporting the wallet's own for a persona login would tell the page it is talking to an identity that signed nothing in that flow.Operational note
A site whose ACL names your holder DID keeps working — pick "My wallet's own identity" once. A site you give a persona needs that persona on its ACL; the first failed sign-in says so and names the DID.
Pre-merge checklist
npm run lintclean (tsc -b, not--noEmit)npm run buildcleannpm test— 666 tests, 0 failures (8 new; 2 rewritten where the guard moved)dist/background.jssingle bundle, no dynamicimport(), nochrome.cookies, no staticcontent_scripts, nocookiespermissionvault/task-signer.tsimportsvta(layer 5 → 4, downward); the persona orchestration stays in the extension so no sideways import is neededChannelSigner's two arms are both live local shapes, not a legacy wire spelling